简介:这份资源面向具备一定R语言基础、希望上手真实回归建模的数据分析学习者与从业者,围绕首尔自行车共享需求预测这一经典赛题展开。数据源自首尔自行车租赁系统,目标是根据温度、小时、季节、假期等变量预测每小时所需自行车数量,以支撑租赁车辆的稳定调度。压缩包共4个文件,约1.71MB,包含R脚本、Markdown说明、Word分析报告与CSV数据集,分别对应建模代码、项目说明、结果呈现与原始数据,结构完整、便于复现。已有257人学习下载。资源完整呈现了从数据读取、特征处理到模型训练与评估的全流程,涵盖CUBIST、正则化随机森林、分类回归树、K近邻与条件推断树等模型,并借助R²、RMSE、MAE等指标比较性能,还包含变量重要性分析,指出温度与小时是最具影响力的因素,适合作为回归分析与交叉验证的实操参考。
1. 首尔自行车共享需求预测:为什么 R 的回归分析仍是性价比最高的起点
首尔自行车共享系统(Seoul Bike / Ttareungyi)公开了一份逐小时租赁记录,字段涵盖日期、时间、季节、节假日、温度、湿度、风速、能见度、露点、太阳辐射、降雨量和降雪量,目标变量是每小时租借的自行车数量。这个数据集在回归分析圈子里被反复使用,原因很直接:它同时具备时间序列特征、天气特征和类别特征,量级适中,跑得动,又能暴露真实建模中的绝大多数坑。用 R 语言做回归分析来预测每小时所需自行车数量,本质上是一个「多特征数值预测」问题,核心诉求是让运营方提前知道某个时段该调度多少车、往哪个站点补车。
这篇文章面向两类人:一类是刚学完 R 基础语法、想找一个完整项目练手的分析师;另一类是用过 Python 建模、想看看 R 生态在回归任务上到底顺不顺手的数据从业者。我会从数据读入、特征工程、模型选型一路写到调参和排错,中间穿插我自己踩过的坑。读完你至少能拿到一条可复现的建模链路,而不是停留在「知道要用回归」这个层面。
2. 数据读入与特征工程:把小时级租赁记录变成回归能吃的矩阵
2.1 先搞清楚每一列到底是什么类型
拿到SeoulBikeData.csv之后,第一件事不是急着lm(),而是把列类型过一遍。原始文件常见的问题是:日期是字符串、季节和节假日是中文或英文类别、降雨量列名带空格或特殊字符。R 读入时如果stringsAsFactors处理不当,后续建模会直接报错或者把类别当连续变量处理。
# 读入首尔自行车数据,注意编码和列名处理 bike <- read.csv("SeoulBikeData.csv", fileEncoding = "UTF-8", stringsAsFactors = FALSE) # 统一列名,去掉空格和特殊符号 names(bike) <- c("date", "hour", "rented", "season", "holiday", "functioning", "temp", "humidity", "wind", "visibility", "dew_point", "solar", "rainfall", "snowfall") # 检查各列类型 str(bike) # 日期转 Date,类别转 factor bike$date <- as.Date(bike$date, format = "%d/%m/%Y") bike$season <- factor(bike$season) bike$holiday <- factor(bike$holiday) bike$functioning <- factor(bike$functioning)这段代码的关键点有三个:fileEncoding防止中文系统下读入乱码;手动重命名列是为了后续公式书写方便,避免反引号包裹带空格的列名;as.Date的格式必须和原始文件一致,首尔这份数据常见的是日/月/年,写错会得到一片 NA。functioning这一列表示当天系统是否正常运营,如果为「No」说明数据不可用,建模前应该过滤掉。
2.2 从日期和时间里榨出真正有用的特征
原始数据给了date和hour,但直接把它们丢进回归模型意义不大。真正有预测力的是「星期几」「是否周末」「月份」「是否高峰时段」这类派生特征。这一步做得好不好,往往比换模型带来的提升还大。
# 派生时间特征 bike$weekday <- weekdays(bike$date) bike$is_weekend <- ifelse(bike$weekday %in% c("Saturday", "Sunday"), 1, 0) bike$month <- as.integer(format(bike$date, "%m")) # 构造高峰时段标记:早高峰 7-9,晚高峰 17-19 bike$peak <- ifelse(bike$hour %in% c(7, 8, 9, 17, 18, 19), 1, 0) # 把 weekday 也转成 factor bike$weekday <- factor(bike$weekday, levels = c("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")) # 过滤掉系统非正常运营的记录 bike <- bike[bike$functioning == "Yes", ]is_weekend和peak用 0/1 编码而不是 factor,是因为它们在回归里更接近二值指示变量,解释起来也直观。weekday保留 factor 并手动指定 levels,是为了让模型输出的系数顺序可控,不然 R 默认按字母排序,周三跑到周一前面,看结果时容易懵。过滤functioning这一步很多人会忘,留着「No」的记录相当于往训练集里掺噪声。
2.3 天气变量的非线性处理与共线性检查
温度、湿度、风速这些天气变量和租赁量之间往往不是直线关系。温度太低或太高骑车的人都少,中间区间最多,这是个典型的倒 U 型。直接放线性项会欠拟合,常见做法是加二次项或者分箱。
# 温度二次项,捕捉倒 U 型关系 bike$temp_sq <- bike$temp^2 # 检查数值变量之间的相关性 num_cols <- c("temp", "humidity", "wind", "visibility", "dew_point", "solar", "rainfall", "snowfall") cor_matrix <- cor(bike[, num_cols]) print(round(cor_matrix, 2)) # 如果 dew_point 和 temp 相关性过高,考虑去掉一个 # 常见做法是保留 temp,去掉 dew_point跑完cor之后你会看到dew_point和temp的相关性通常很高,这在物理上也好理解——露点温度本身就受气温影响。两个高度共线的变量同时放进线性回归,系数估计会不稳定,方差膨胀。我一般会保留temp和temp_sq,把dew_point拿掉。solar和temp也可能有中等相关,但通常没到必须删的程度,可以先留着看模型表现。
提示:特征工程阶段每加一列,都问自己一句「这列在预测时刻是否已知」。比如
rented的滞后项在真实预测场景里可能拿不到,训练时用了会造成数据泄漏。
3. 线性回归与正则化:从 lm 到 glmnet 的选型逻辑
3.1 先用 lm 建立可解释的基线
不管最后用什么模型,我都会先跑一个普通最小二乘回归当基线。原因很简单:它快、系数可解释、能帮你判断哪些特征方向是对的。如果temp的系数是正的、rainfall的系数是负的,说明数据没读反。
# 建立线性回归基线模型 lm_fit <- lm(rented ~ hour + temp + temp_sq + humidity + wind + visibility + solar + rainfall + snowfall + season + holiday + is_weekend + peak, data = bike) summary(lm_fit) # 看残差图 par(mfrow = c(2, 2)) plot(lm_fit)summary输出里重点看三样:调整后 R 方、各系数的 p 值、F 统计量。调整后 R 方告诉你模型解释了多大比例的方差;p 值帮你判断哪些变量显著;F 统计量看整体是否有效。残差图如果出现明显的喇叭口或者曲线趋势,说明线性假设不成立,需要考虑变换或者换模型。
hour直接作为数值放进去其实不太合理,因为小时和租赁量的关系是周期性的,23 点和 0 点应该接近而不是差很远。更稳妥的做法是把hour转成 factor,或者用正弦余弦编码。
# 小时的正弦余弦编码,保留周期性 bike$hour_sin <- sin(2 * pi * bike$hour / 24) bike$hour_cos <- cos(2 * pi * bike$hour / 24)3.2 岭回归和 Lasso:什么时候该上正则化
当特征数量变多、共线性变严重时,lm的系数会变得很大且不稳定。岭回归(Ridge)通过加 L2 惩罚把系数往零压,Lasso 用 L1 惩罚还能顺便做特征选择。在 R 里用glmnet包实现,核心是选好lambda。
library(glmnet) # 构造设计矩阵,factor 会自动展开为哑变量 x <- model.matrix(rented ~ hour_sin + hour_cos + temp + temp_sq + humidity + wind + visibility + solar + rainfall + snowfall + season + holiday + is_weekend + peak, data = bike)[, -1] y <- bike$rented # 十折交叉验证选 lambda set.seed(42) cv_ridge <- cv.glmnet(x, y, alpha = 0, nfolds = 10) cv_lasso <- cv.glmnet(x, y, alpha = 1, nfolds = 10) # 最优 lambda 下的系数 coef(cv_ridge, s = "lambda.min") coef(cv_lasso, s = "lambda.min")alpha = 0是岭回归,alpha = 1是 Lasso,alpha在 0 到 1 之间是弹性网。nfolds = 10是十折交叉验证,数据量不大时够用。lambda.min是交叉验证误差最小时的 lambda,另一个选项lambda.1se更保守,会给一个更简单的模型。我一般先用lambda.min看效果,如果系数太多不好解释再换lambda.1se。
model.matrix那行末尾的[, -1]是去掉截距列,因为glmnet默认会自己处理截距。忘了去掉会多一列全 1,虽然不致命但没必要。
3.3 模型评估:别只看 R 方
R 方高不代表模型好用,尤其是当你在预测任务上关心的是误差量级。我习惯同时看 RMSE、MAE 和 R 方,并且用交叉验证而不是训练集上的拟合值来评估。
# 用交叉验证预测值评估 pred_ridge <- predict(cv_ridge, newx = x, s = "lambda.min") rmse <- sqrt(mean((y - pred_ridge)^2)) mae <- mean(abs(y - pred_ridge)) r2 <- 1 - sum((y - pred_ridge)^2) / sum((y - mean(y))^2) cat("RMSE:", round(rmse, 2), "\n") cat("MAE:", round(mae, 2), "\n") cat("R2:", round(r2, 3), "\n")RMSE 对极端误差更敏感,MAE 更稳健,两个一起看能判断误差分布是否偏斜。如果 RMSE 远大于 MAE,说明存在一些预测得特别离谱的样本,值得单独拎出来看看是不是异常值或者特殊天气。
4. 树模型与调参:随机森林和 XGBoost 在 R 里怎么落地
4.1 随机森林:几乎不用调参就能打的基准
线性模型假设特征和目标的关系是线性的(或者你手动构造了非线性项),而随机森林能自动捕捉交互和非线性。在 R 里用ranger包比randomForest快不少,尤其是数据量上去之后。
library(ranger) set.seed(42) rf_fit <- ranger(rented ~ hour + temp + humidity + wind + visibility + solar + rainfall + snowfall + season + holiday + is_weekend + peak, data = bike, num.trees = 500, mtry = 4, importance = "impurity") print(rf_fit$r.squared) sort(rf_fit$variable.importance, decreasing = TRUE)num.trees = 500是树的数量,一般 300 到 1000 之间够用,再多边际收益递减。mtry是每次分裂随机抽取的变量数,回归任务默认是总变量数的三分之一左右,这里手动设成 4 是试出来的。importance = "impurity"输出变量重要性,能帮你看哪些特征贡献大。
随机森林的缺点是预测值倾向于落在训练集范围内,对极端值的预测能力弱。如果你发现模型从来不会预测出特别高或特别低的租赁量,这是原因之一。
4.2 XGBoost:调参空间大,但要抓重点
XGBoost 在 R 里有xgboost包,性能强但参数多。新手容易一上来就网格搜索所有参数,跑半天没结果。我的经验是优先调三个:max_depth、eta(学习率)、nrounds。
library(xgboost) # 构造训练矩阵 dtrain <- xgb.DMatrix(data = x, label = y) # 基础参数 params <- list( objective = "reg:squarederror", max_depth = 6, eta = 0.05, subsample = 0.8, colsample_bytree = 0.8 ) # 交叉验证找最佳轮数 set.seed(42) cv_xgb <- xgb.cv( params = params, data = dtrain, nrounds = 1000, nfold = 5, early_stopping_rounds = 50, verbose = 0 ) best_n <- cv_xgb$best_iteration cat("最佳轮数:", best_n, "\n") # 用最佳轮数训练最终模型 xgb_fit <- xgb.train( params = params, data = dtrain, nrounds = best_n )max_depth = 6是树的深度,太深容易过拟合,太浅欠拟合,4 到 8 之间先试。eta = 0.05是学习率,小一点更稳但需要更多轮数。early_stopping_rounds = 50表示如果 50 轮验证误差没下降就停,省时间。subsample和colsample_bytree是行采样和列采样比例,加一点随机性有助于防过拟合。
xgb.cv返回的best_iteration就是交叉验证误差最小时的轮数,直接拿来训练最终模型,比手动试快得多。
4.3 树模型的特征重要性怎么读
XGBoost 和随机森林都能输出特征重要性,但两者的计算方式不同。随机森林的impurity重要性偏向于取值多的变量,XGBoost 的gain更可靠一些。
# XGBoost 特征重要性 imp <- xgb.importance(model = xgb_fit) print(imp) # 可视化 xgb.plot.importance(imp, top_n = 10)看重要性时注意:hour相关的特征(hour_sin、hour_cos或者原始hour)通常排最前,其次是temp和season。如果rainfall或snowfall排在很后面,不一定说明它们不重要,而是因为这两个变量大部分时候是 0,信息量集中在少数样本上,树模型不容易利用。
5. 避坑与排查:首尔自行车回归建模里最容易翻车的五件事
5.1 现象:模型 R 方很高但预测值全是平均值附近
原因:数据泄漏。最常见的是把rented的某种变换(比如对数、滞后)当特征,或者训练集和测试集没有按时间切分,随机切分导致相邻小时的信息互相渗透。
解决:按时间顺序切分训练集和测试集,比如前 80% 的时间段做训练,后 20% 做测试。如果一定要随机切分,至少确保同一日期的记录不跨集。
5.2 现象:glmnet报错「x 和 y 长度不一致」
原因:model.matrix生成的矩阵行数和y不一致,通常是因为构造x时用了na.omit或者数据里有 NA,而y没有同步过滤。
解决:在构造x和y之前先统一处理缺失值,或者用同一个索引过滤。
complete_idx <- complete.cases(bike[, c("rented", "temp", "humidity")]) bike_clean <- bike[complete_idx, ]5.3 现象:随机森林预测值范围比真实值窄很多
原因:随机森林的预测是叶子节点均值的平均,天然倾向于向均值收缩,对极端值的预测能力弱。
解决:如果业务上关心高峰时段的预测精度,可以对目标变量做变换(比如开方),或者换用 XGBoost 并调大max_depth。另一个思路是分时段建模,高峰和非高峰各训一个模型。
5.4 现象:hour作为数值放进线性回归,残差图呈周期性波动
原因:小时是周期性变量,23 和 0 在数值上差 23,但在业务上只差 1 小时。线性模型无法捕捉这种循环关系。
解决:用正弦余弦编码,或者把hour转成 factor。正弦余弦编码更省自由度,factor 更灵活但会增加很多哑变量。
5.5 现象:XGBoost 训练时验证误差先降后升
原因:过拟合。树的数量太多或者max_depth太大。
解决:用early_stopping_rounds自动停在最佳轮数,同时适当减小max_depth、增大subsample比例。如果数据量小,还可以加min_child_weight参数限制叶子节点的最小样本数。
6. 把模型推到可用:时间序列交叉验证与残差诊断的实操细节
前面用的都是普通交叉验证,但自行车租赁数据本质上是时间序列,随机切分会让模型「偷看」未来。更严谨的做法是滚动窗口交叉验证,R 里可以用rsample包的rolling_origin实现。
library(rsample) # 按时间排序 bike_sorted <- bike[order(bike$date, bike$hour), ] # 滚动窗口交叉验证 rolls <- rolling_origin( data = bike_sorted, initial = 4000, assess = 500, cumulative = TRUE ) # 对每个切分跑模型并记录 RMSE rmse_list <- c() for (i in 1:nrow(rolls)) { train_data <- analysis(rolls$splits[[i]]) test_data <- assessment(rolls$splits[[i]]) fit <- ranger(rented ~ hour + temp + humidity + wind + visibility + solar + rainfall + snowfall + season + holiday + is_weekend + peak, data = train_data, num.trees = 300) pred <- predict(fit, data = test_data)$predictions rmse_list[i] <- sqrt(mean((test_data$rented - pred)^2)) } cat("滚动窗口 RMSE 均值:", round(mean(rmse_list), 2), "\n") cat("滚动窗口 RMSE 标准差:", round(sd(rmse_list), 2), "\n")initial = 4000表示第一个训练窗口用 4000 条记录,assess = 500表示每次用后续 500 条做测试,cumulative = TRUE表示训练窗口逐步扩大。这样评估出来的 RMSE 更接近真实上线后的表现。如果滚动窗口的 RMSE 标准差很大,说明模型在不同时间段的表现不稳定,可能需要加入更多时间特征或者分季节建模。
残差诊断也别跳过。把残差按小时画箱线图,看看有没有某些时段系统性偏高或偏低。
# 残差按小时分析 bike_sorted$resid <- bike_sorted$rented - predict(rf_fit, data = bike_sorted)$predictions boxplot(resid ~ hour, data = bike_sorted, main = "残差按小时分布")如果早上 8 点的残差中位数明显偏离 0,说明模型在这个时段有系统性偏差,可以考虑单独为高峰时段加特征或者调整样本权重。
最后说一个我自己的习惯:每次跑完模型,我都会挑几天把预测值和真实值画在同一张图上,肉眼过一遍。有一次我发现模型在雨天预测得特别差,回去查数据才发现降雨量列有大量 0 值但实际是「微量降雨」被记成了 0,这种问题光看指标是发现不了的。模型分析这件事,指标是底线,肉眼是保险。希望帮到你。
本文还有配套的精品资源,点击获取