← 全部项目

CitiBike需求预测与动态定价

这是一个共享单车需求预测与价格建议项目:尝试预测某个站点在指定小时的借出量和还入量,并给出价格调整建议。

跟着图解看项目怎么完成从数据到结果,逐步查看处理过程
2026 · CitiBike

项目介绍

我在这个课程项目中尝试解决两个相连的问题:一个站点在指定小时会借出、还入多少辆车,以及根据这些需求和现有供给,可以给出怎样的价格建议。项目使用官方历史骑行记录,完成了数据整理、模型训练、价格规则和网页演示,也保留了代码、看板与课程报告。离线预测实验和接口演示分别实现,当前接口尚未调用训练好的 Spark 模型。

1,453万原始骑行记录
438.8万站点—小时聚合记录
99.3%站点容量匹配率
3项代码复核识别的风险字段
PythonPySparkPandasParquetscikit-learnFastAPIPyechartsCodex

项目阶段

先看数据怎样变化,再看模型、价格与展示。图形展示主要变化,处理方法和代码依据可以按需展开。

阶段一至三:将骑行记录整理成模型输入

阶段一至三参考 Medallion 分层数据处理方法:按处理程度分别保存原始、清洗和汇总结果。

Raw原始骑行记录

一行是一次骑行

保留原始文件
清洗
Bronze干净的骑行记录

一行仍是一次骑行

保存清洗结果
按站点小时汇总+ 补充字段
Silver站点小时记录

一行是一个站点的一小时

保存汇总结果
每一步都保存结果。 后续处理可以从已保存的数据继续,不必每次重新读取原始 CSV。

1读取并清洗骑行记录

程序读取约 1,453 万条官方骑行记录,统一时间、按骑行编号去重,并处理缺少起终点站的记录。清洗后每行仍代表一次骑行,结果保存为 Bronze 文件,供后续处理继续读取。

PySpark 负责批量读取和清洗 · Parquet 按列保存中间结果

查看处理方法与依据收起处理方法与依据
读取 16 个原始 CSV约 1,453 万条记录 · 一行代表一次骑行
PySpark 读取并保存本次生成 73 个 Parquet 数据文件
按规则清洗骑行记录
转换时间→按编号去重→检查站点编号
清洗后的骑行表4 个数据文件
一行仍是一次骑行
站点资料表站点编号、站名、坐标
骑行表通过编号关联
73 份和 4 份是这次保存的数据文件数量。PySpark 将同一目录中的文件作为一份数据表读取。
清洗程序执行哪些规则?
处理对象具体处理目的
借出、归还时间将 started_at、ended_at 转为时间类型。计算骑行时长,归入对应小时。
骑行编号按 ride_id 去重。避免同一次骑行重复计数。
起终点站编号删除任意一端站点编号缺失的记录。让借还事件能够归到具体站点。
站名与经纬度提取到站点表,骑行表保留站点编号。减少重复保存的站点资料。

PySpark 怎样处理? 使用 Python 编写处理逻辑,由 Spark 按分区执行读取、清洗和汇总。

为什么分阶段保存? 参考 Shakleen 项目的 Medallion 方法,按处理程度保存数据,后续实验可以从已经完成的步骤继续。Parquet 按列保存,后续可以选择读取需要的字段。

2按站点和小时统计借还数量

程序将一次骑行拆成借出、还入两个事件,分别计入对应站点和小时,得到约 438.8 万条站点小时记录。跨小时骑行的借出与归还会进入不同的统计时段。

PySpark 负责事件拆分 · 按“站点编号+小时”分组求和

查看处理方法与依据收起处理方法与依据
一次骑行,产生两个事件演示:08:50 从 A 站借出,09:10 在 B 站归还
按借出时间归类A 站 · 08—09 点借出 +1使用起点站和 started_at
按归还时间归类B 站 · 09—10 点还入 +1使用终点站和 ended_at
与其他骑行事件一起汇总按“站点编号+小时”分组,分别累加借出、还入次数
A、B 站和时间均为假设示例。借出与归还使用各自的时间,所以一次跨小时骑行会进入两条不同的小时记录。
这次骑行为小时表增加了什么?
站点时段借出次数还入次数
A 站08:00—09:00+10
B 站09:00—10:000+1

PySpark 读取清洗后的 4 个 Parquet 数据文件,将事件时间归到所在小时,再按站点和小时分组求和。同一站点、同一小时的事件即使分散在不同文件,也会一起统计。

约 438.8 万条站点小时记录统计单位从“一次骑行”变成“一个站点的一个小时”。

原 Silver 根据实际发生的借还事件汇总,没有完整补齐所有站点、所有小时的零需求记录。

3补充信息,准备模型输入

小时表补充时间、天气和站点容量等信息,训练入口再选择需要的字段。项目还生成过标准化和 PCA 处理后的 Gold 文件,但最终训练实际读取 Silver。

PySpark 添加与关联字段 · 经纬度邻近匹配站点 · 标准化与 PCA 处理 Gold

查看处理方法与依据收起处理方法与依据
从时间提取小时、星期、高峰等
从骑行记录统计比例、平均时长
关联外部资料天气、站点容量
补充后的 Silver 站点小时表在借还数量之外,增加用于描述需求的信息
这些信息从哪里得到,模型用了哪些?
信息类别来源与处理方式原版训练
时间信息从时间提取月份、星期、小时、高峰等,并添加小时、星期的周期编码。使用 13 个字段。
骑行统计按站点和小时计算电动车比例、会员比例和平均骑行时长。原版使用 3 个,后续修复移除。
天气信息按日期关联平均气温、累计降水和最大风速。使用 3 个字段。
站点容量按经纬度邻近关系匹配站点资料,取得容量。保存在数据中,最终训练未使用。

电动车比例该小时借出的电动车次数 ÷ 总借出次数

会员比例该小时会员借车次数 ÷ 总借出次数

平均骑行时长该小时借出骑行的总时长 ÷ 总借出次数

没有借出事件时,代码将这三个统计值设为 0。三个字段依赖目标小时的完整骑行结果,因此后续修复将它们从预测输入中移除。

23 点→0 点→1 点

用 sin/cos 编码表达时间的循环关系,使 23 点与 0 点在数值表示上也能体现相邻关系。

Silver
选择 19 个字段↓最终模型训练
标准化 → PCA↓Gold · 5 个主成分已生成,未接入最终训练
标准化调整字段的数值尺度;PCA 将多个原字段组合成较少的综合数值。它改变字段的表示方式,不会把 438 万行变成 350 万行。

4尝试自动调参,再用预设参数完成训练

先尝试Hyperopt 自动调参

尝试多组参数,比较验证集误差

完成部分搜索,后续因内存不足中断
最终使用预设参数训练

固定参数,分别训练借出与还入模型

完成训练、评价并保存模型

先尝试用 Hyperopt 搜索参数,后续运行因内存不足中断,最终使用预设参数完成训练。分别预测站点每小时的借出量和还入量。

PySpark MLlib 训练模型 · Hyperopt 搜索参数 · RMSE 与 R² 评价结果

查看处理方法与依据收起处理方法与依据

改用预设参数,完成最终训练

使用预设参数训练两个模型

读取 Silver 的 19 个输入字段,分别学习同一小时的借出量与还入量。

  1. 1
    将完整数据随机分成三组
    约 438.8 万条 Silver 站点小时记录
    80% 训练集约 351 万条
    10% 验证集约 44 万条
    10% 测试集约 44 万条
    集合用途
    训练集学习字段与借还量之间的关系。
    验证集检查预测表现,供方案比较。
    测试集独立评价最终模型。

    按整行随机分配:输入字段与实际借还量一起进入同一组。随机种子固定为 42,实际行数略有浮动。

  2. 2
    用同一组预设参数,分别训练借出与还入模型
    最终随机森林使用的参数
    参数实际设置含义
    每个模型的树数量50 棵借出、还入模型各有 50 棵树
    最大深度10限制一棵树的划分深度
    最大分箱数24限制连续字段的分箱数量
    子节点最少样本数10 条每个子节点至少保留 10 条记录
    训练采样比例80%与训练/验证/测试分组的比例不同
    借出模型19 个输入字段 → 借出量50 棵回归树
    还入模型19 个输入字段 → 还入量50 棵回归树
    每棵树怎样学习?

    回归树是预测数值的决策树。程序在训练记录中尝试字段和切分位置,选择能够减少分组内借还量差异的划分;最后用末端分组的平均数量给出预测。

    假设示例:是否处于高峰时段?
    否 → 再根据其他字段划分是 → 再根据其他字段划分

    每棵树给出数量预测 → 随机森林取平均

    每棵树可以使用多个字段,没有固定的“时间树”或“天气树”。字段候选具有随机性,具体分支根据训练数据选择;图示不代表保存模型的真实分支。

  3. 3
    计算预测误差,保存模型和结果
    验证集和测试集 → 分别预测 → 分别计算 RMSE、R²
    原版随机森林的评价结果
    模型验证 RMSE验证 R²测试 RMSE测试 R²
    借出模型3.0430.6413.0380.644
    还入模型3.8440.4363.8480.436
    RMSE 和 R² 怎样评价模型?
    每条记录的预测值 − 实际值↓将每个差值平方↓求平均,再开平方↓得到 RMSE,越小表示误差越小

    RMSE 与预测数量使用相同单位,较大的误差受到更重的影响。R² 衡量模型相对“总是预测实际值均值”的改善程度,它不是准确率,也可能为负数。

    这些成绩对应原版字段和随机切分。后续字段与切分修复见阶段七 ↗

早期搜索记录使用 11 个时间字段,最终训练使用 19 个字段。这是先后进行的两次训练尝试,分数差异不能只归因于参数选择方式。

5将供需数量转换成价格建议

空车位有余量 ↑
← 车辆不足车辆有余量 →
空车位不足 ↓

选择一个场景,查看输入与价格建议。

建议价格 $6.74 · 1.50 倍

可用 10 辆、空车位 20 个;需求参考为借出 20 辆、还入 5 辆。

车辆缺口比例为 −1,按缺车规则计算并取上限 1.50 倍。

图中使用假设数据演示规则。轴表示供需差的正负;实际倍率按下方阈值和公式计算。

根据车辆与空车位的供需差,按预设公式计算价格倍率,返回建议价格和调整原因。

Python 计算供需差 · 阈值与上下限约束价格倍率

查看处理方法与依据收起处理方法与依据

先比较供需,再计算倍率和价格

价格规则由程序预先设置。当前接口从历史数据取得借还需求参考,再结合提交的车辆数和空车位数计算建议价格。

当前车辆数 − 借出需求参考得到车辆供需差
↓
当前空车位数 − 还入需求参考得到车位供需差
↓
分别除以当前供给得到两项相对供需差 b、d
↓
按顺序匹配规则得到价格倍率 × 基础价格 $4.49

b = 车辆供需差 ÷ max(当前车辆数, 1);d = 车位供需差 ÷ max(当前空车位数, 1)。max 表示取较大值,供给为零时用 1 作分母。负值表示不足,正值表示有余量。

程序采用第一条满足条件的规则

顺序情况触发条件倍率公式限制
1车辆与车位同时不足b < −0.5,且 d < −0.51.3 + |b + d| ÷ 2 × 0.7最高 2.0
2缺车b < −0.31 + |b| × 0.6最高 1.5
3车位不足d < −0.31 + |d| × 0.4最高 1.3
4车辆、车位都充足b > 0.5,且 d > 0.51 − min(b, d) × 0.25最低 0.7
5车辆充足b > 0.51 − b × 0.15最低 0.8
6其余情况以上条件均未满足1.0保持基础价格

| | 表示绝对值,min 表示取较小值。程序先将倍率保留两位小数,再乘以基础价格,将金额保留两位小数。

看一次完整计算:10 辆车,借出需求为 20 辆
车辆:10 − 20 = −10;空车位:20 − 5 = 15
↓
b = −10 ÷ 10 = −1;d = 15 ÷ 20 = 0.75
↓
满足缺车条件:1 + 1 × 0.6 = 1.6按上限取 1.50 倍
↓
$4.49 × 1.50 → 建议价格 $6.74

$4.49 是项目设定的基础价格。当前完成了价格规则演示,尚未通过实际运营实验检验调价效果。

6通过接口和看板展示结果

已经生成的历史分析数据
接口

接收一次查询

↓

计算并返回价格建议

看板

汇总已有数据

↓

展示需求、价格与站点差异

演示一次查询 → 查看返回内容

以下使用假设输入说明请求和返回内容。

提交站点 A、指定小时当前车辆 10 辆 · 空车位 20 个
↓
假设查到该小时历史记录借出 20 辆 · 还入 5 辆
↓
接口应用价格规则返回 1.50 倍 · $6.74 · 缺车原因

接口负责接收查询并返回需求参考和价格建议;看板负责把已有分析结果画成图,方便观察不同时间、站点的情况。

FastAPI 提供接口 · Pydantic 检查请求字段 · Pandas 读取数据 · Pyecharts 制作图表

查看处理方法与依据收起处理方法与依据

一次查询怎样得到价格建议

提交站点、时间和当前库存FastAPI 接收请求,Pydantic 检查字段
↓
查找对应站点、日期和小时的历史借还量没有对应记录时,取所有站点相同钟点的平均借还量
↓
计算车辆与空车位的供需差调用阶段五的价格规则
↓
返回需求参考、倍率、建议价格和原因

FastAPI 提供程序调用入口;Pydantic 检查请求字段的类型和约束;Pandas 读取与筛选历史数据。当前接口使用历史数据提供需求参考。

功能提交什么?得到什么?
查询一次价格建议站点、时间、当前车辆数和空车位数借还需求参考、倍率、建议价格和原因
查看未来 24 小时的演示结果站点、当前库存和站点容量逐小时的需求参考和价格建议
查看站点排行发起排行查询历史分析数据中,平均价格倍率最高和最低的各 5 个站点
24 小时演示怎样更新库存?
本小时车辆数 − 借出量 + 还入量
↓
将车辆数限制在 0 到站点容量之间空车位数 = 站点容量 − 车辆数
↓
使用更新后的库存,继续计算下一小时

程序用这个方式模拟库存变化;需求参考仍来自历史数据。历史文件不存在或为空时,接口使用高峰、周末等预设时段规则。

看板把已有结果展示成图

Pandas 读取并汇总分析数据
↓
PyEcharts 生成交互图表
↓
查看每小时借还需求、价格分布和站点差异

接口方便其他程序提交查询;看板方便使用者观察整体结果。模型训练与当前接口分别实现,接口收到请求时使用已有数据计算价格。

7复核数据,修复后做冒烟测试

同一批 24,000 条记录
第一组

保留原来 19 个字段

随机切分

↓

作为本轮比较起点

第二组

移除 3 个字段

随机切分

↓

观察删除字段的影响

第三组

移除 3 个字段

时间切分

↓

检查按时间训练的表现

三组都分别训练借出与还入模型。第一、二组使用完全相同的训练、验证和测试记录。

复核后,我移除了三个需要等目标小时结束才能计算的字段,并加入时间切分。随后用同一批 2.4 万条记录进行了三组对照,检查修复后的训练流程和预测表现。

移除三个字段 · 按时间切分 · PySpark 运行三组对照

查看处理方法与依据收起处理方法与依据

移除三个需要事后统计的字段

字段统计的是什么?为什么移除?
电动车比例该小时借出骑行中,电动车的占比需要该小时完整的借出记录,小时开始前无法得到。
会员比例该小时借出骑行中,会员的占比
平均骑行时长该小时借出骑行的平均时长还需要等待相应骑行结束。

移除三个字段后,模型保留 16 个输入字段。三组都使用相同的预设随机森林参数,使比较重点放在字段与分组方式上。

第三组按小时先后划分数据

按小时从早到晚排列同一个小时的所有站点进入同一组
↓
前 80% 的小时用于训练
↓
接下来 10% 的小时用于验证
↓
最后 10% 的小时用于测试
本次 24,000 条样本的实际划分(2026 年)
集合使用的时间段记录数
训练集1 月 1 日至 5 月 2 日 23:0018,343
验证集5 月 3 日 00:00 至 5 月 17 日 12:002,844
测试集5 月 17 日 13:00 至 5 月 31 日 23:002,813

比例按小时数划分。不同小时的记录数不同,因此记录数不必恰好为 80%、10%、10%。这样用较早的数据训练,再用较晚的数据评价。

用三组结果检查修复后的变化

组别字段与切分方式借出量测试 R²还入量测试 R²
第一组19 个字段 · 随机切分0.6330.464
第二组16 个字段 · 相同随机切分0.1210.116
第三组16 个字段 · 时间切分0.0950.101

第一、二组使用相同记录进行训练和评价,删除三个字段后分数明显下降。第三组进一步检查按时间训练的表现;它使用了不同的测试记录。

修改字段与时间划分
↓
运行三组训练与评价
↓
保存训练摘要与比较结果完成 24,000 条样本的冒烟测试

冒烟测试用于确认修改后的关键流程能够完整运行。本次没有保存新模型,也没有替换原接口和看板;表中成绩对应这批样本。

返回项目阶段目录 ↑

图片与材料

分析结果

  1. 01

    第一版随机森林的测试集R²为借出量0.644、还入量0.436;梯度提升树分别为0.142和0.139。在当次字段、参数与随机划分条件下,随机森林的两项结果均更高。R²用于衡量预测对实际数量变化的解释程度,不能直接当作准确率。

  2. 02

    第一版随机森林的借出量RMSE约为3.04,还入量约为3.85。RMSE是预测误差平方平均后再开方的结果,单位为次/站点小时;这两项数值记录了原实验中的误差水平。

  3. 03

    第一版形成了按车辆与车位供需缺口计算价格倍率、建议价格和理由的策略结果,并通过看板、接口及答辩展示呈现需求与定价的使用方式。价格建议属于规则模拟,尚无真实运营收益验证。

  4. 04

    后续修复删除三个同小时统计字段,改用时间切分,并完成2.4万行三组冒烟测试;该结果用于核对字段与切分的影响,尚未完成全量正式重训。

复核与修复记录

复核发现,电动车比例、会员比例和平均骑行时长取自目标小时的完整骑行统计,第一版又采用随机切分,因此原指标只能按该实验条件解释。修复分支删除这三个字段,改按时间切分,并完成2.4万行三组冒烟测试。全量模型重训和旧接口、看板更新尚未完成;保留的天气字段还需核对预测时刻是否可取得。

我与 AI 如何完成项目

首版:Claude Code + DeepSeek Pro|复核修复:Codex + ChatGPT-sol

项目由本人独立完成。我负责确定业务问题、判断数据粒度、选择技术路线、拆分阶段、检查模型指标、解释工作台疑点、决定修复范围,并确认哪些结果能够公开。AI负责调研参考项目、展开代码实现、生成测试与文档初稿;最终的指标解释、错误修正和停止条件由本人确认。

首版方案怎样形成

我先把课程要求和动态定价想法交给Claude Code与DeepSeek Pro,再让AI查找需求预测、站点供需和价格激励的已有项目。方案经过讨论后被拆成数据整理、特征工程、模型训练、策略计算、API和展示页面。

AI在实施阶段负责什么

ai主要生成PySpark、Pandas、scikit-learn、FastAPI和可视化代码,并参与方案讨论与解释。我逐阶段阅读输出、运行程序、检查文件和指标,并决定是否进入下一阶段。

使用工作台对数据进行复核

Codex 在旧项目代码中识别出三个依赖目标小时统计的风险字段,工作台按配置清单检查 Silver 小时表,再计算参考预测并审查依据。这次工作台运行没有调用内置 AI;字段判断、代码修复和结果解释由外部代码核对与后续讨论完成。

我怎样决定停止位置

修复版完成了安全字段筛选、时间切分和冒烟测试以后,我保留了三组比较结果及修复记录。全量重训、天气字段可用时间的核对,以及旧API与看板更新仍列为后续工作,当前展示明确说明已完成的范围。

参考与使用说明

Shakleen / CitiBike-Demand-Prediction ↗

参考了大规模CitiBike数据处理、需求预测和Web接口的整体技术路线;本项目重新选择了数据时间范围、聚合口径、特征和模型。

Bikenomics — NYC Citibike Optimization

本地保留了该项目的README快照,参考了站点供需、价格激励和用户引导的产品思路。原仓库地址没有保留,因此网站暂不提供外部链接。

结果适用范围
  • 2.4万行结果用于验证修复后的代码、数据切分和指标计算能够完整运行,不能代表千万级数据完整重训后的最终精度。
  • 当前FastAPI和旧可视化仍用于展示课程版产品链路,页面中会明确标注Legacy边界。
  • 动态定价属于策略模拟,不代表真实商业环境中的因果收益。

查看项目动画图解 ↗

下一个项目AdventureWorks 经营分析
项目截图打开原文件 ↗
100%滚轮缩放 · 拖动查看