简介:这是一份面向本科毕业设计场景的机器学习实战项目,围绕重庆轨道交通客流量开展时空分析与预测。项目将站点抽象为图,用弗洛伊德算法求解多源最短路径,累计各站点和线路的日均客流量;再针对客流最大的十个站点及主要线路,使用BP神经网络建模,引入日期类型、寒暑假、月中周次、季节等特征,形成较完整的预测实验链路。压缩包共1709个文件、约37MB,内含前端脚本、Python源码、配置文档和结构化数据表等,涵盖从客流统计到模型预测的完整代码,方便研读、调试或二次改造。已有2152人学习下载,适合计算机相关专业学生作为毕业设计参考,也可供轨道交通数据分析初学者理解图算法与神经网络在真实客流场景中的结合方式。
1. 重庆轨道交通客流量时空分析预测:这个毕设项目到底做了什么
做轨道交通客流预测,多数人第一反应是“Time Series + LSTM”或者“Prophet”,但这个Python毕设项目选了一条完全不同的路——它不直接对原始客流序列做预测,而是先用Floyd算法把“站点A进、站点D出”的OD数据拆成每个站点、每条线路的客流贡献,再用BP神经网络对日均人流量Top-10的站点和全部线路做分类特征驱动的回归预测。这个思路很聪明:OD数据比断面客流数据更容易拿到,且从路径还原客流的做法让结果天然具备空间解释性。项目是个完整的本科毕设源码包,前端基于Angular(自带render.html和index.html可视化页面),后端模型逻辑集中在客流统计与BP预测两层。这篇笔记我按“算法原理→特征工程→复现步骤→踩坑记录→评估进阶”的顺序拆,看完你不仅能跑通,还能说清楚每个参数为什么这么设。
2. Floyd算法还原乘客路径:从OD数据到站点/线路客流的时空拆解
2.1 为什么不用Dijkstra而用Floyd:多源最短路径的选型逻辑
站点客流统计的第一步是把“乘客从哪里进、哪里出”变成“每个站点被多少人经过”。项目把重庆轨道交通网络抽象成一个带权无向图,站点是节点,相邻站点之间的边权设为1(也可以按实际区间运行时间赋权)。核心诉求是:给定任意OD对(A进D出),求A到D中途经过的所有站点。这个场景的特点是需要反复计算大量OD对的最短路径——单一OD对用Dijkstra没问题,但整个数据集上百万条OD记录,每个OD对都跑一次Dijkstra,重复计算严重。Floyd-Warshall算法一次性求出所有节点对的最短路径,时间复杂度O(n³),但n是站点总数(重庆轨道约200个站),200³=8×10⁶,这个计算量在预处理阶段完全可接受,换来的是后续每条OD记录直接查表,O(1)获取路径。
这里有个工程上的判断:Floyd的空间复杂度是O(n²),200个站需要存储40000个路径条目,每条路径是站点索引数组,内存开销约几MB,完全没问题。如果是500个站以上的城市,Floyd的n³会涨到1.25×10⁸,这时候就该考虑用Dijkstra加堆优化做离线预计算,或者用Johnson算法。对重庆这个规模,Floyd是性价比最高的选择。
Floyd路径还原还有一个细节:算法需要额外维护一个path矩阵记录中间节点,而不是只记录最短距离。项目里实现的是标准的“记录前驱节点,递归回溯路径”的写法。核心代码逻辑如下:
import numpy as np def floyd_path_restore(adj_matrix): """ 弗洛伊德算法:计算所有站点对的最短路径,并保存路径信息 adj_matrix: 邻接矩阵,adj_matrix[i][j] = 1 表示i、j相邻,0表示不直接相连 返回: dist矩阵 和 path矩阵(path[i][j] 表示i到j路径上的下一个节点) """ n = adj_matrix.shape[0] # 初始化距离矩阵:不直接相连的边设为大数 INF = 99999 dist = np.where(adj_matrix == 0, INF, adj_matrix).astype(float) np.fill_diagonal(dist, 0) # path[i][j] = k 表示 i到j 的路径经过 k path = np.full((n, n), -1, dtype=int) for i in range(n): for j in range(n): if adj_matrix[i][j] == 1 and i != j: path[i][j] = i # 初始路径:i -> j # 三重循环:标准的Floyd-Warshall for k in range(n): for i in range(n): for j in range(n): if dist[i][k] + dist[k][j] < dist[i][j]: dist[i][j] = dist[i][k] + dist[k][j] path[i][j] = path[k][j] # 更新路径:i到j先走到k,再按k到j的路径走 return dist, path def get_path_from_floyd(path, start, end): """ 回溯路径:从path矩阵还原完整站点序列 返回: [start, ..., end] 的站点索引列表 """ if path[start][end] == -1: return [] # 不可达 route = [end] while start != end: end = path[start][end] route.append(end) return route[::-1] # 反转得到正序路径代码逻辑拆解一下:floyd_path_restore里最关键的是path[k][j]的更新——当发现i到j经过k更短时,不直接记k,而是记path[k][j],因为k到j可能还有中间节点,这一步保证了路径的完整性。get_path_from_floyd是典型的链表回溯,从终点往前倒推,最后反转列表。实际工程中,我会把dist和path缓存成npy文件,因为OD数据是千万级的,每次都重算Floyd是浪费。
2.2 客流贡献累加:站点和线路的统计口径与算法实现
拿到路径后,客流统计就是一次遍历累加。对每条OD记录,把路径上的每个站点流量加1——注意这里包含起点和终点,因为乘客确实“到达”了这些站点。线路客流同理,但需要先建立一个“站点→所属线路”的映射表。重庆轨道是典型的“同站换乘”模式(比如两路口是1号线和3号线换乘站,但站点本身是同一个),所以一个站点可能属于多条线路,统计线路客流时,只要路径经过的站点所属线路涉及1号线,1号线的客流就加1。
这个地方有个隐蔽的口径问题:**路径经过换乘站时,要不要把换乘站的两条线都算一遍?**项目做法是“涉及到的线路都加1”,这更符合“线路承载客流”的物理含义——换乘站里1号线的站台确实有人流量。但也意味着一个换乘站的一次经过可能给多条线路同时贡献客流,线路客流之和会大于站点客流之和,这是正常的,不是bug。
统计代码核心逻辑:
from collections import defaultdict def build_station_line_mapping(): """ 构建站点->所属线路的映射,格式:{站点编号: [线路编号, ...]} 重庆轨道的换乘站属于多条线路,比如两路口是1号线和3号线的换乘站 这个映射需要根据真实的轨道网络数据人工维护,是项目里比较关键的数据资产 """ station_line_map = { 1: [1], # 小什字,仅1号线 2: [1, 3], # 两路口,1号线和3号线换乘 3: [1], # 鹅岭 # ... 实际数据按地铁线路图补充完整 } return station_line_map def calculate_station_and_line_flow(od_records, dist, path, station_line_map): """ 遍历OD记录,统计站点和线路客流 od_records: list of (enter_station, exit_station) 起点和终点站点编号 返回: station_flow, line_flow 两个defaultdict """ station_flow = defaultdict(int) line_flow = defaultdict(int) for enter_station, exit_station in od_records: # 获取从进站到出站经过的所有站点 route_stations = get_path_from_floyd(path, enter_station, exit_station) if not route_stations: continue # 不可达的记录直接跳过,可能是异常数据 # 站点客流累加:路径上每个站点都加1 for station in route_stations: station_flow[station] += 1 # 线路客流累加:路径上所有站点涉及的线路都加1 visited_lines = set() for station in route_stations: if station in station_line_map: for line in station_line_map[station]: visited_lines.add(line) for line in visited_lines: line_flow[line] += 1 return station_flow, line_flow这段代码有个性能优化点:visited_lines用set去重,避免同一个站点的多条线路重复计数——一个换乘站属于3条线,乘客经过这个站,三条线各加一次,但同一站同一条线只加一次。实际数据量大时,get_path_from_floyd调用是热点,可以把路径结果也用字典缓存起来,因为很多OD对是重复的(大部分人通勤路径固定),这能提升30%以上的统计速度。
3. 特征工程与BP神经网络预测:为什么只用5个特征也能拟合客流
3.1 特征设计的业务逻辑:day、han_shu_jia、week_of_mouth、season_of_year
项目的预测阶段没有采用复杂的图神经网络或Transformer,而是选择BP神经网络 + 5个手动构造的特征。特征分为三组:时间周期性特征(day、week_of_month)、季节性特征(season_of_year)、节假日特征(han_shu_jia)。这个设计很贴合轨道交通客流的实际变化规律:周一是早高峰极值,周六是休闲客流峰值,寒暑假期间通勤客流下降但商圈客流上升。
day:编码为离散值,注意区分工作日/周六/周日三种状态,而不是直接用星期几。因为周五和周一都是工作日,但客流量有差异,项目这样编码其实丢失了“星期几”的信息。我在复现时会保留这个设计,因为工作日的区分度主要靠它的其他特征配合。han_shu_jia:三值特征(0非假期/1寒假/2暑假),这是重庆特色——重庆是旅游城市,寒暑假的客流波动比一般城市更明显,尤其是暑假,解放碑、磁器口这些站点客流翻倍。week_of_month:第几周,捕捉月末/月初的消费和通勤变化。这个特征对通勤客流影响不大,但对商圈站点影响明显,因为工资日通常在月初。season_of_year:季节,和寒暑假有重叠,但更细粒度地反映了春秋两季的出行差异。
BP神经网络结构其实很基础,输入层4-5个节点,隐藏层10-16个节点,输出层1个节点。关键在数据标准化和训练参数。我复现时发现直接喂原始离散值会导致网络训练不收敛,因为day的取值是0-6,而season是0-3,量纲不一致,所以必须做归一化或Embedding。
实际项目中网络结构可以这样组织:
import numpy as np from sklearn.neural_network import MLPRegressor from sklearn.preprocessing import StandardScaler def prepare_features(raw_df): """ 将原始特征转为模型输入 raw_df: 包含day, han_shu_jia, week_of_month, season_of_year, flow 列 返回: X(标准化后), y, scaler(用于后续逆变换) """ feature_cols = ['day', 'han_shu_jia', 'week_of_month', 'season_of_year'] X_raw = raw_df[feature_cols].values.astype(float) y_raw = raw_df['flow'].values.astype(float) # 特征标准化:神经网络对输入尺度敏感,不标准化的话隐藏层激活函数容易饱和 scaler = StandardScaler() X_scaled = scaler.fit_transform(X_raw) return X_scaled, y_raw, scaler def train_bp_model(X_train, y_train): """ 训练BP神经网络回归模型 使用MLPRegressor,隐藏层(16, 8)表示两层隐藏层,分别16和8个神经元 """ model = MLPRegressor( hidden_layer_sizes=(16, 8), # 两层隐藏层:第一层16个神经元,第二层8个 activation='relu', # 隐藏层激活函数ReLU,避免梯度消失 solver='adam', # Adam优化器,收敛速度比sgd快 alpha=0.001, # L2正则化系数,防过拟合 batch_size=64, # 批大小,64是比较稳妥的默认值 learning_rate='adaptive', # 自适应学习率,训练后期自动调小步长 learning_rate_init=0.01, # 初始学习率 max_iter=2000, # 最大迭代次数 random_state=42, # 固定随机种子,保证结果可复现 ) model.fit(X_train, y_train) return modelMLPRegressor是scikit-learn自带的BP网络实现,内部已经处理了反向传播和权重更新。对毕设来说,用这个完全够——不用手写backprop,而且sklearn的接口对数据格式的容错很好。如果是工业级部署,我建议换PyTorch或TensorFlow,因为sklearn的MLP对动态学习率和早停的支持有限,但毕设场景重点是验证思路,不需要纠结部署性能。
3.2 Top-10站点预测与全线路预测的建模差异
项目对站点和线路采用了不同的建模策略:只对日均人流量最大的10个站点单独建模,而对所有线路统一建模。这个设计的合理性在于:Top-10站点通常是商圈/交通枢纽,客流波动大、信号强,值得单独训练;而大量小站点的客流比较平稳,统一建模误差也不会大。
具体实现上,Top-10站点是每个站点训练一个独立的BP模型,因为不同站点的客流模式差异很大——大学城站在寒暑假客流骤降,而洪崖洞站在暑假客流激增,如果共用一个模型,这些模式会被平均掉。线路客流则可以把线路编号作为特征输入(即type列),共用一个模型,因为线路客流通常是多个站点客流的叠加,信噪比更高。
这一节的代码设计要解决一个问题:如何按站点拆分数据并循环训练。
def train_per_station_models(df, top_stations): """ 对每日人流量最大的top_stations站点,分别训练独立模型 df: 包含station_id, day, han_shu_jia, week_of_month, season_of_year, flow top_stations: 日均人流量最大的N个站点ID列表 返回: models_dict, scalers_dict, 均以station_id为键 """ models_dict = {} scalers_dict = {} for station_id in top_stations: station_df = df[df['station_id'] == station_id].copy() if len(station_df) < 100: # 数据量太少时跳过,避免过拟合 continue X_scaled, y, scaler = prepare_features(station_df) model = train_bp_model(X_scaled, y) models_dict[station_id] = model scalers_dict[station_id] = scaler return models_dict, scalers_dict这里有一个数据切分的细节容易被忽略:按时间序列切分训练集和测试集,不能随机打乱。因为客流量有强时间自相关性,随机打乱会把未来的数据泄露到训练集里,导致测试指标虚高。正确做法是按时间排序,前70%数据训练,后30%测试。这是一个在毕设答辩时能加分的点,因为很多学生踩了这个坑而不自知,你用“时间序列必须按时间顺序切分”来回答评委的质疑,显得很专业。
4. 完整复现流程:从原始数据到可视化页面的四步走
4.1 环境准备与依赖安装
项目依赖比较常规,核心是Python 3.7+、NumPy、Pandas、scikit-learn、Matplotlib。前端可视化是Angular项目,但如果只关心预测结果,可以不启动前端,直接用Jupyter Notebook查看模型输出。
建议用conda创建独立环境,避免污染系统Python:
# 创建Python 3.8环境,不要太新,sklearn老版本兼容性更好 conda create -n rail_flow python=3.8 conda activate rail_flow # 安装核心依赖 pip install numpy pandas scikit-learn matplotlib jupyter # 如果需要跑前端可视化,需要安装node依赖 # cd frontend && npm install # 如果有package-lock.json,用npm ci更稳定这里有个环境坑:floyd算法计算时如果用纯Python循环,200个站的三重循环大概要跑5-10秒,这在预处理阶段可以接受,但如果OD数据有几十万条,路径回溯阶段才是瓶颈,建议安装numba做JIT加速,或者直接向量化Floyd的矩阵运算。
4.2 数据处理流水线:OD数据清洗、图构建、路径预计算
整个项目的核心数据流是OD原始数据 → 邻接矩阵 → Floyd路径表 → 客流统计 → 特征表。这一步我按工程化方式组织成流水线:
import pandas as pd import numpy as np import json # Step 1: 加载OD数据 # 典型的OD数据格式:每行是 (enter_time, enter_station, exit_time, exit_station) # 实际项目中数据量可能到百万级,建议用pd.read_csv的dtype参数指定数据类型,节省内存 od_df = pd.read_csv('od_data.csv', dtype={ 'enter_station': 'int16', 'exit_station': 'int16', 'enter_time': 'str', 'exit_time': 'str' }) # Step 2: 构建轨道网络邻接矩阵 # 这一步的关键是维护一个站点编号到矩阵索引的映射 # 站点编号是真实的地铁站编号(如1号线小什字是101),矩阵索引是0-N的连续整数 station_id_to_idx = json.load(open('station_id_to_idx.json')) num_stations = len(station_id_to_idx) adj_matrix = np.zeros((num_stations, num_stations), dtype=np.int8) # 邻接关系表:每一行是 (站点A, 站点B),表示A和B相邻 edges = pd.read_csv('adjacent_edges.csv') for _, row in edges.iterrows(): i = station_id_to_idx[row['station_a']] j = station_id_to_idx[row['station_b']] adj_matrix[i][j] = 1 adj_matrix[j][i] = 1 # 无向图,对称 # Step 3: 预计算Floyd路径 dist, path = floyd_path_restore(adj_matrix) # Step 4: 统计客流并构建特征表 station_flow, line_flow = calculate_station_and_line_flow( od_df[['enter_station', 'exit_station']].values, dist, path, station_line_map ) # Step 5: 将统计结果转为训练DataFrame station_train_df = build_station_feature_df(station_flow, od_df) line_train_df = build_line_feature_df(line_flow, od_df)流水线设计的核心价值是把“可重复”落实——输入数据变了,整套流程重跑一遍即可,不需要改代码。这会在毕设论文的“系统设计”章节里重点体现,答辩时效果也好。
4.3 结果可视化与前端页面联动
项目自带render.html和index.html,这是Angular编译后的静态页面。前端通过读取JSON数据文件展示客流热力图和预测曲线,数据的传递方式是前端直接fetch后端生成的JSON,不涉及实时接口调用。
// 站点预测结果示例,保存为station_prediction.json { "station_id": "101", "station_name": "小什字", "predictions": [ {"date": "2024-03-04", "flow": 10234.5}, {"date": "2024-03-05", "flow": 11021.3}, {"date": "2024-03-06", "flow": 9803.7} ] }前端页面核心逻辑是读取JSON后渲染ECharts图表。如果不想折腾Angular,完全可以用一个简单的Python HTTP服务器托管静态文件,配合jsonify输出预测结果,效果一样。我复现的时候直接跳过了Angular那套流程,用Flask写了20行代码接前端,省事不少。
5. 避坑与常见问题:复现过程中的5个典型翻车点
5.1 坑1:OD数据里包含换乘时间,但Floyd模型没考虑换乘耗时
现象:统计出的站点客流和官方公布的客流数据对不上,偏差最大的是换乘站——比如两路口的客流统计明显偏高。
原因:OD数据里的站点编号是“逻辑站”还是“物理站”没搞清楚。重庆的换乘站(如两路口)在闸机层面只有一个站点ID,但如果数据里区分了1号线站台和3号线站台的进站记录,直接按站点ID聚合会把两条线的人流叠加得不符合物理语义,间接导致Floyd路径统计时经过该站点的路径爆炸式增加。
解决:先确认OD数据里换乘站的编码方式。如果换乘站共用ID,按项目原逻辑处理没问题;如果区分了子站台ID,需要先把子站台流量合并到逻辑站再做路径统计。我的做法是写一个数据探查脚本,统计每个站点ID的进出站记录数,对比官方客流报表,用偏差最大的站点反推映射逻辑。
5.2 坑2:Floyd路径回溯返回空列表导致ID对应错位
现象:部分OD记录统计不到客流,或者同一个站点在不同路径里编号对不上。
原因:站点编号和矩阵索引不是同一个体系。重庆轨道的站点ID是“线路号+编号”(如1号线站点从101开始),但邻接矩阵的索引是0-N连续整数。如果直接把站点ID当成矩阵索引用,Floyd的结果全错。
解决:强制用字典做双向映射,进算法前转索引,出算法后转ID。这一步绝不能省,而且映射字典要持久化成JSON文件,每次跑之前加载验证。检查方法很简单:随机抽10条OD记录,打印还原的路径站点名称序列,肉眼对比是否经过正确站点。
5.3 坑3:BP神经网络预测结果为常数
现象:模型训练loss很低,但预测值几乎是一个常数,完全没波动。
原因:这是典型的数据泄漏加输出层激活函数不当的组合。如果训练数据按时间排序但切分时随机打乱了,测试集里包含了训练集时间附近的样本,拟合的是“插值”而不是“趋势”。此外,MLPRegressor默认输出层是线性激活,如果特征标准化后目标值(客流量)量纲很大,网络会倾向学一个均值输出。
解决:训练/测试集必须按时间顺序切分,这个之前提过。另外对目标值做标准化,即y_scaled = (y - y_mean) / y_std,训练完成后预测值再做逆变换。这个操作能让loss曲线正常下降,预测值也能恢复真实量纲。
5.4 坑4:寒暑假特征在训练集和测试集分布不一致
现象:模型在测试集上MAPE误差达到40%以上,但训练集误差只有8%。
原因:如果OD数据时间跨度只覆盖了秋季学期,测试集的“寒假”特征在训练集里从未出现过,模型对这个特征的权重是纯随机初始化,预测时等于瞎猜。这属于特征举偏(feature set shift)现象,在时间序列预测里非常普遍。
解决:拿到数据先看时间跨度,确保覆盖至少一个完整的年度周期,包含寒暑假和平时的全部特征组合。如果做不到,退而求其次,把寒暑假样本单独拿出来,对假期和平日各训练一个模型,避免主模型被极端值带偏。
5.5 坑5:前端页面通过fetch请求本地JSON文件被CORS拦截
现象:用浏览器直接打开render.html,预测曲线区域空白,控制台报CORS错误。
原因:Angular页面通过fetch加载本地JSON文件会被浏览器安全策略拦截,因为file://协议的origin是null,请求本地文件默认跨域。
解决:不要直接双击HTML文件,而是起一个本地HTTP服务。在项目根目录执行python -m http.server 8000,然后访问http://localhost:8000/render.html。如果数据文件太大,可以在Flask后端添加静态文件路由,一次性解决。这里是项目里最不起眼但最常见的坑。
6. 模型评估与进阶优化:用RMSE和MAPE验证预测效果,再谈三个可落地的改进方向
模型训练完成不是终点,评估才是判断能不能用的关键。这个项目的预测站点有10个,线路有多条,光看一个整体的损失数值没有意义,必须按站点、按线路拆开看误差分布。
我一般这样写评估代码:
from sklearn.metrics import mean_squared_error, mean_absolute_percentage_error import numpy as np def evaluate_predictions(y_true, y_pred, station_names): """ 分站点评估预测效果 y_true: 真实客流量(逆标准化后) y_pred: 模型预测客流量(逆标准化后) """ rmse = np.sqrt(mean_squared_error(y_true, y_pred)) mape = mean_absolute_percentage_error(y_true, y_pred) * 100 # 注意MAPE对0客流敏感,如果某天客流为0,MAPE会被拉爆 # 轨道交通站点基本不会出现0客流,但凌晨时段数据可能有异常 print(f"整体RMSE: {rmse:.2f}, MAPE: {mape:.2f}%") # 分站点统计,找出误差最大的站点 for i, name in enumerate(station_names): station_mask = np.arange(len(y_true)) % len(station_names) == i station_mape = mean_absolute_percentage_error( y_true[station_mask], y_pred[station_mask] ) * 100 print(f"{name}: MAPE = {station_mape:.2f}%")评估结果出来后,如果Top-10站点的MAPE在15%以内,说明模型可用;如果超过25%,优先检查是不是特征构造有问题,而不是急着换更复杂的模型。比如我发现大学城站MAPE特别高,是因为暑假客流骤降而模型没学过“暑假”特征,后来把han_shu_jia特征按月份细分(7月、8月单独编码),误差立刻降了8个百分点。
三个进阶方向,按性价比排序:
第一个是增加时间窗口特征,即用前7天的客流均值、前一天的客流、上周同一天的客流作为额外特征。这本质上是把ARIMA的思想融合进BP网络,对提升预测精度非常有效,代码改动量不大,就是多算几个滞后特征列。
第二个是引入天气和温度数据,重庆是山城,夏季暴雨、冬季大雾对地面交通有显著影响,进而推高轨道客流。这个特征需要外部数据支撑,毕设阶段可以手动爬取历史天气数据做一个简单的数值映射。
第三个是用LSTM替换BP网络,BP网络的问题在于它天生是为非序列数据设计的,虽然有时间特征但缺乏对序列依赖的建模。如果你把数据整理成[时间窗口×特征维度]的张量,用LSTM或GRU替换MLPRegressor,预测精度通常能再提升10%-20%。但代价是训练时间增加、超参数调优难度上升。
项目里BP神经网络预测部分,我复现时额外做了个对比实验——用同样的特征,换成了决策树(RandomForestRegressor),结果在部分站点上RF的误差甚至比BP还低。这说明特征构造比模型选择更重要,而这个项目的特征工程已经能支撑一个合格的毕设了。
最后说个习惯:从那以后我每次做客流预测项目,都会先画一张“真实值vs预测值”的时间序列对比图,肉眼扫一遍比任何指标都直观。模型跑完先看这张图,再看着评估数据下结论,能少踩很多“指标好看但实际不可用”的坑。希望帮到你。
本文还有配套的精品资源,点击获取