简介:这是一份Python数据分析实战案例教程,以公交车站点设置优化为业务场景,完整示范从提出问题、理解数据、清洗建模到可视化解读的分析流程。PDF共1个文件,约144KB,内容围绕公交车GPS监控数据与乘客刷卡数据展开:用Pandas完成重复值与异常值清洗、降采样归约及缺失值填充,按站点统计上下车客流量,结合geopy计算站点间实际距离,并应用DBSCAN聚类算法划分站点群组,最后从增设站点、合并低客流站、调整站点位置等角度给出优化策略,整个案例配有可运行的Python代码段,便于边读边练。适合具备Python基础、想通过真实业务场景提升数据分析能力的初学者,也可用作课程设计或案例教学参考。截至目前已有178人学习,内容短小精悍却覆盖数据分析常见环节,是快速上手实战的不错选择。
1. 公交车站点优化:一份能直接跑的 Python 数据分析实战案例
公交站点怎么设才合理,乘客嫌远、司机嫌堵、运营方嫌亏,这问题靠拍脑袋吵三天也没结论。这份 Python 数据分析实战案例教程,恰恰把公交车站点设置优化分析做成了可落地的编程实例:从刷卡数据清洗到站点调整建议,每一步都有可运行的案例代码。它不是什么高深算法,而是一套完整的数据分析流程,适合刚学完 Python 基础、想拿真实业务练手的人,也适合做交通规划但写代码不熟的一线工程师照着改。看完你就能理解:为什么数据清洗比建模更耗时间,以及怎样把一个模糊的“线路不合理”翻译成能计算的指标。
2. 先定指标再写代码:公交站点优化分析的分析框架与数据准备
2.1 从“站点不合理”到可量化指标:OD 客流、站点间距与服务覆盖
做公交数据分析前,第一件事不是打开 Python 环境,而是把业务问题翻译成可计算的指标。站点设置优化,通常盯三个维度:客流需求、服务覆盖、站间距合理性。客流需求看的是 OD(起讫点)分布和断面客流,也就是某一段路上到底有多少人坐车;服务覆盖看的是站点周边步行半径内能服务多少居住区和岗位;站间距看的是相邻站点是过密还是过稀,过密导致车速慢,过稀导致步行距离过长。
这套案例教程的做法,是先建立一张“站点—线路—方向”的基础表,把每个站点的经纬度、站点序号、所属线路存好。然后拿公交 IC 卡刷卡数据去匹配,得到每个站点的上下车人数。这里的关键是:原始数据里根本没有“断面客流”这个概念,必须自己定义。我一般会按线路和方向,把连续站点之间的一段路作为“断面”,先算区间内所有上车人数减去下车人数的累计值,这就是这一段的实时客流。
指标定义清楚了,后面的代码才不会白写。实际拆这份案例时,你会发现它的核心思路和我平时做项目很接近:先画一个“数据字典”,明确每个字段是什么格式、什么含义,再动手清洗。如果跳过这一步,后面 join 的时候十有八九会翻车。
提示:这部分的产出不是图表,而是一张“指标计算口径表”。把口径定下来,后续所有代码都围着它转。
2.2 数据清洗实战:GPS、IC 卡刷卡记录与站点表的对齐
公交数据分析里,数据清洗的工作量通常占六成以上。案例里的原始数据主要来自三张表:IC 卡刷卡流水、车辆 GPS 报站记录、站点基础信息表。IC 卡记录有卡号、线路编号、车辆编号、上下车标记和刷卡时间;GPS 记录有车辆编号、进站时间、站点编号。两张表通过“车辆编号 + 时间区间”对齐,这是整份案例的第一个难点。
常见的做法是:先用站点基础表把线路上的站点顺序编号排好,再对 GPS 进站记录做排序,得到车辆经过每个站点的实际时刻表。下面这段代码是案例里清洗 IC 卡数据的第一步,我按自己的习惯加了注释:
import pandas as pd # 读取 IC 卡刷卡数据,原始 CSV 没有表头,手动指定字段名 ic_card = pd.read_csv( "ic_card_raw.csv", header=0, names=["card_id", "line_id", "vehicle_id", "station_seq", "up_down_flag", "swipe_time"], dtype={"card_id": str, "line_id": str, "station_seq": int}, parse_dates=["swipe_time"] ) # 过滤明显异常:刷卡时间早于首班车或晚于末班车 ic_card = ic_card[ (ic_card["swipe_time"] >= "2024-01-01 05:30:00") & (ic_card["swipe_time"] <= "2024-01-01 23:30:00") ] # 上下车标记标准化:1 表示上车,2 表示下车,其余直接剔除 ic_card = ic_card[ic_card["up_down_flag"].isin([1, 2])] # 删除重复刷卡记录(同一张卡、同一时间、同一车辆,判定为重复) ic_card = ic_card.drop_duplicates( subset=["card_id", "vehicle_id", "swipe_time"] ) print(ic_card.shape) print(ic_card["up_down_flag"].value_counts())这段代码里几个参数值得说明。parse_dates直接把刷卡时间解析成时间类型,后面做时间区间匹配才高效;up_down_flag的取值 1 和 2 不同数据源可能相反,需要先看数据字典确认;drop_duplicates的subset选三个字段,是因为同一张卡在同一辆车同一秒刷两次几乎不可能是正常行为。
清洗完 IC 卡,还要处理 GPS 进站记录。GPS 原始数据通常有漂移,同一个站点可能报两次进站。我的习惯是给 GPS 记录加一列“进站序号”,按车辆和日期分组,再对进站时间排序,后一条时间减去前一条时间,间隔小于 3 秒的直接删掉。这套去重逻辑在案例里有对应实现,但参数我建议不照抄,因为不同车厂报站设备的采样频率不一样。
2.3 数据字典与字段口径:哪些字段直接决定后面能不能算
拆这份案例时,我发现它对“字段口径”的说明比代码本身更值钱。比如 IC 卡里的station_seq,有的城市指线路物理站点序号,有的指车辆报站序号,如果物理站点序号是 1 到 35,但某辆车因为跳站少报了三个,那两条记录的station_seq就不能直接对齐。
我把案例里的关键字段整理成了一张表,方便对照检查自己的数据:
| 字段 | 含义 | 口径与注意点 |
|---|---|---|
| card_id | 刷卡卡号 | 脱敏后的字符串,不能直接当数字处理 |
| line_id | 线路编号 | 同一线路可能有主线和区间线,区间线要单独标注 |
| vehicle_id | 车辆编号 | GPS 数据里可能带车牌号,需统一编码格式 |
| station_seq | 站点序号 | 区分“物理序号”和“报站序号”,两者可能不一致 |
| up_down_flag | 上下车标记 | 1/2 含义需查数据字典,不同城市可能相反 |
| swipe_time | 刷卡时间 | 注意时区与服务器时间偏移,通常需要校正 |
| gps_arrive_time | 进站时间 | 与刷卡时间对齐时用车辆编号,别用站点编号 |
为什么字段口径这么重要?因为我见过有人直接把 IC 卡的station_seq和站点表的station_seq做关联,结果无监督聚类发现某个站一天有 8000 人上车,明显是把区间车和主线车的原始线路编号搞混了。案例教程里反复强调“先验数,再建表”,就是在说这个事。
数据准备阶段还有一个常被忽略的动作:检查站点表里有没有孤立站点。有的站点物理上存在,但没有任何 GPS 记录,说明这站已经被跳停或者数据缺失。处理方式是把孤立站点单独抽出来人工核对,而不是直接补 0,否则后面的覆盖分析会出现假盲区。
3. 客流断面与满载率:用 Python 定位“该挪的站”
3.1 按运行方向切分断面:聚合刷卡数据的核心逻辑
站点优化的第一步,是找出客流最拥挤的断面。所谓断面,就是两个相邻站点之间的路段。要把刷卡记录落到断面上,核心是按“线路 + 方向 + 站点序号”做累计。方向字段一般由 GPS 的上下行决定,1 是上行、2 是下行,这个在案例数据里已经标注好。
我一般先把车辆进站记录和刷卡记录合并,给每条刷卡记录打上“最近进站时间”和“最近站点序号”。注意这里不能用刷卡时间直接匹配最近的 GPS 进站时间,而要先按车辆编号分组,再用pd.merge_asof做前向匹配。这样能避免一辆车同时段的数据被另一辆车干扰。
import pandas as pd # gps_data 已经清洗过:车辆编号、进站时间、站点序号、方向 gps_data = pd.read_csv("gps_clean.csv", parse_dates=["arrive_time"]) # 刷卡数据也清洗完毕,这里只保留上车记录,对位“最近进站” board = ic_card[ic_card["up_down_flag"] == 1].copy() # 按车辆编号分组,刷卡时间向前匹配最近一次进站时间 merged = pd.merge_asof( board.sort_values("swipe_time"), gps_data.sort_values("arrive_time"), left_on="swipe_time", right_on="arrive_time", by="vehicle_id", direction="backward", tolerance=pd.Timedelta("3min") ) # 只保留能命中站点的记录,计算断面用 merged = merged.dropna(subset=["station_seq", "direction"]) print(merged.head())merge_asof是处理这种“事件对齐进站记录”最合适的方法,比用searchsorted手写循环要快一个数量级。direction="backward"表示刷卡时间在进站时间之后,也就是乘客在上车之后到下一站之间刷卡;tolerance=3min是容差,超过 3 分钟没匹配上就认为这条记录异常,可能是司机没报站。容差这个参数建议根据线路平均站间距来调,高峰时段可以放宽到 5 分钟,平峰时段 3 分钟就够。
匹配完成后,每个上车记录就有了站点序号,再按“线路 + 方向 + 站点序号”分组计数,就得到每个站点的上车人数。下车记录按同样方式匹配到“最近进站”但方向和逻辑相反,即刷卡时间在进站时间之后、出站时间之前,这里不展开。
3.2 满载率计算与阈值判定:怎么算“过于拥挤”
有了各站点上下车人数,就能算断面客流。公式是:当前断面上车人数 = 上一个断面上车人数 + 本站上车人数 - 本站下车人数。注意方向的开口,首站断面客流等于首站上车人数,末站通常归零,如果末站不为零,说明前面有数据漏配。
案例里有一个我很认同的细节:满载率不直接用人数/核定载客,而是分时段算。早高峰 7:00-9:00 的断面客流和全天平均断面客流,调站优先级完全不同。满载率计算代码示意如下:
# 计算每个站点区间的上下车累计 df = merged.groupby( ["line_id", "direction", "station_seq"] )["card_id"].count().reset_index(name="persons") # 按线路与方向排序,形成每个站点的上车/下车序列 df = df.sort_values(["line_id", "direction", "station_seq"]) df["net_in"] = df["persons"] # 本站净上车人数 # 断面客流 = 累计净上车人数,用 groupby 的 cumsum 实现分段累计 df["section_load"] = df.groupby( ["line_id", "direction"] )["net_in"].cumsum() # 满载率:断面客流 / 车辆定员,定员按 80 人估算 df["load_factor"] = df["section_load"] / 80cumsum在这里的作用是分段累计净上车人数,得到每个断面的实时载客。定员 80 不是标准值,普通 10 米公交车核载约 70-90 人,实际定员要看具体车型,案例教程给的 80 仅作演示,真正做方案时应该按线路车辆型号去查公告数据。
满载率怎么判阈值?我的经验是:早高峰断面满载率超过 100% 持续三个断面以上,说明这一段需要加密班次或调整站点分散客流;平峰满载率低于 30%,说明站点密度过大,车辆频繁停车反而拉低了运营速度。案例把这套逻辑做了可视化,输出一张“拥挤断面排行榜”,直接指出哪一段最需要动。
3.3 断面热力图:把结果画成能汇报的图
做数据分析的都知道,给领导汇报不能只丢 Excel 表,图比表好说服人。案例里用 matplotlib 画了断面热力图,横轴是时间段,纵轴是站点序号,颜色深浅代表满载率。这类图对站点优化特别直观,一眼能看到早高峰哪一段红得发紫。
import matplotlib.pyplot as plt import numpy as np # pivot_table 生成“时间段 x 站点序号”的满载率矩阵 heat_data = df.pivot_table( index="station_seq", columns="time_slot", values="load_factor", aggfunc="mean" ) # 设置颜色映射,红黄蓝三个色阶一眼区分拥挤程度 plt.figure(figsize=(12, 8)) plt.imshow(heat_data, aspect="auto", cmap="RdYlBu_r", vmin=0, vmax=1.2) plt.colorbar(label="load factor") plt.xlabel("time slot") plt.ylabel("station seq") plt.title("section load factor heatmap") # 网格线辅助定位 plt.grid(which="both", color="white", linewidth=0.5) plt.tight_layout() plt.savefig("section_heatmap.png", dpi=150)cmap="RdYlBu_r"是反向红黄蓝渐变,满载率越高越红。vmin=0, vmax=1.2是颜色映射的下上限,vmax 设为 1.2 是为了让超过 120% 的断面保持最深红色,否则极端值会把整张图拉偏。画完图我一般还会标注几个关键站点名,用plt.annotate在纵轴位置加上线路里最重要的枢纽站,这样汇报时听众不用对着站点序号猜。
4. 站点覆盖与服务重叠:量化盲区和浪费
4.1 缓冲区分析与站点覆盖盲区识别
乘客走多远愿意坐公交?常规标准是步行 300-500 米,500 米是舒适上限。站点覆盖盲区就是画半径为 500 米的缓冲区,然后和居住区、岗位数据求交集,覆盖不到的区域就是盲区。这类分析在案例里用的是geopandas,本质是矢量图形的空间叠加计算。
import geopandas as gpd from shapely.geometry import Point # stations 是站点表,包含经度和纬度字段 stations = gpd.read_file("stations.shp") residential = gpd.read_file("residential_blocks.shp") # 设置坐标系为 WGS84,后面的距离计算需要转换投影坐标系 stations = stations.set_crs("EPSG:4326") stations_proj = stations.to_crs("EPSG:32650") # UTM 50N # 先生成 500m 缓冲区,再与居住小区叠置求覆盖面积 buffers = stations_proj.copy() buffers["geometry"] = stations_proj["geometry"].buffer(500) # 叠加分析:居住小区中有多少面积落在缓冲区内部 coverage = gpd.overlay(residential, buffers, how="intersection") covered_area = coverage.geometry.area.sum() total_area = residential.geometry.area.sum() print(f"covered ratio: {covered_area / total_area:.2%}")buffer(500)的单位取决于当前坐标系,这个坑必须讲清楚:如果在 EPSG:4326 上直接 buffer(500),单位是度,相当于 500 度,结果完全没意义。所以先to_crs("EPSG:32650")转成米制投影坐标系再算。案例里用的 UTM 50N 针对东经 117°-123° 的区域,如果你的城市在别的经度,要查对应的 UTM 分带。
覆盖盲区识别完还要检查是不是真的“无站可用”。有时地图上显示有盲区,但实际步行 500 米内能到邻线站点,我的做法是把所有线路站点一起做缓冲区,而不是单条线路单独算。这份案例教程讲解的代码逻辑也是基于全网站点展开的,因为单个线路调整一定影响周边其他线路。
4.2 站点间距与重复覆盖指数:哪些站可以合并
站点过密直接导致两个问题:车速上不去、乘客候车时间变长。案例里对站点间距的判断标准按功能区区分:市中心 300 米、城市外围 500 米、郊区 800 米。如果相邻站点间距低于标准的 60%,就判定为“过密”。
import numpy as np # 按线路和方向排序,计算相邻站点距离 stations = stations.sort_values(["line_id", "direction", "station_seq"]) stations["geom_shift"] = stations["geometry"].shift(-1) # 同一线路内才计算距离,跨线路的 shift 结果是 NaN,直接丢弃 same_group = ( stations["line_id"] == stations["line_id"].shift(-1) ) & (stations["direction"] == stations["direction"].shift(-1)) stations["dist_next"] = np.where( same_group, stations["geometry"].distance(stations["geom_shift"]), np.nan ) # 市中心区域阈值设为 300 米,低于 180 米视为过密 dense_stations = stations[ (stations["dist_next"].notna()) & (stations["dist_next"] < 180) & (stations["zone"] == "center") ] print(dense_stations[["line_id", "station_seq", "dist_next"]].head())geometry.distance算的是平面距离,数据已经是投影坐标系,所以结果单位是米。np.where里嵌套same_group条件,是为了防止线路最后一段和下一线路第一段首尾相连算出假距离。180 米这个阈值怎么来的?案例给的说法是“相邻站间距低于标准值 60% 判定过密”,300 米的 60% 就是 180 米,这是一个可配置的经验值,实际落地时建议根据城市步行习惯调整。
重复覆盖指数是另一个合并依据:如果两个相邻站点在 500 米缓冲区内的重叠面积超过单站覆盖面积的 50%,说明这两个站服务的人群高度重叠,存在合并空间。但这个指数不能单独用,要结合断面客流看。如果前一个站早高峰上车 300 人,后一个站上车只有 5 人,合并后对乘客实际影响可控,这种才是真正能调整的站。
案例让我印象最深的一点是:它没有直接把过密站点标记为“删除”,而是输出一份三列清单,分别是“推荐合并站对”“推荐迁移站点”“推荐新增站点”,每一条都附上依据指标。数据分析到这一步,才算是从“统计报告”走向“工程决策”。
5. 避坑指南:公交数据分析常见的五个翻车现场
5.1 早高峰时段划分不一致,断面数据对不上
现象:同一张图上午和下午跑出来结果完全不一样,上午显示某段满载率 150%,下午变成 90%。
原因:上午按 7:00-9:00 聚合,下午按 17:00-19:00 聚合,但刷卡服务器时间与实际北京时间差了 8 分钟,高峰边界被切错。更隐蔽的是巡检把一条线路的高峰定义改了 30 分钟,导致前后对比失真。
解决:统一用“站点到站时刻”而不是刷卡时刻划分时段。我在案例里看到的做法是,先做一次全量时间偏移校正,再用首站发车时刻聚合早高峰,这样不同日期、不同车辆之间口径一致。
5.2 经纬度坐标系混用,站点偏移几百米
现象:站点覆盖盲区分析画出来,站点跑到了河里,缓冲区覆盖面积严重失真。
原因:站点表用的是 GCJ-02 国测局坐标,居住区边界是 WGS84 经纬度,两者叠加前没有统一坐标系。有的还混进了百度 BD-09 坐标,一个城市三种坐标系并存是最常见的坑。
解决:先按城市查清每张数据的坐标系来源,再用geopandas的to_crs统一转成同一投影坐标系。坐标转换后抽样选取 10 个站点,到地图上人工核对,确认没有整体偏移再继续分析。
5.3 上下行方向没分开统计,满载率被平均掉
现象:线路满载率显示 80%,看似合理,但实际一个方向挤爆了,另一个方向空车在跑。
原因:断面客流直接按线路聚合,没有拆方向。下行方向和上行方向的客流差异极不对称,合在一起平均,早高峰问题被平峰稀释。
解决:所有客流计算强制带上direction字段分组,满载率按“线路 + 方向”分别输出。汇报时优先展示“拥挤方向TOP10”,而不是全线路均值榜单。案例教程的每一张图都区分了上下行,这是它做得比较扎实的地方。
5.4 刷卡记录里“下车”数据缺失,OD 只能算半边
现象:有的新能源公交乘车码不需要下车刷卡,原以为按下车记录不缺,实际一数,下车比上车少了三成。
原因:数据源本身读不到下车行为,只能靠“乘客换乘记录”间接推断下车。用这种带缺失的下车数据去算断面客流,累计值越算越虚。
解决:如果下车数据缺失,就改用“满载率上限”法:只看各站上车累计值,不加下车修正,宁可高估拥挤,也不能低估拥挤。如果一定要 OD 数据,那就要换数据方案,比如摄像头客流统计加用户日志,而不是硬在清洗阶段补。
5.5 站点名做关联键,join 之后数据断崖式减少
现象:站点表里 1000 个站,关联后只剩 600 个,大量刷卡记录因站点名匹配不上被丢弃。
原因:站点名存在“人民医院”和“市人民医院”、“一中”和“第一中学”这类同义不同名问题,直接用字符串等值连接必掉数据。
解决:关联键不用站点名,改用经纬度反查最近站点。把刷卡记录定位到站点后,再用站点编号做外键关联。如果必须用名称,至少先做一趟标准化清洗,同音字、带不带“市”字都要统一。案例里我注意到它给站点表加了一个station_key字段,本质也是为了让关联键稳定。
6. 把分析结果变成调整方案:站点评分模型与现场验证
数据分析做到第五章其实还没闭环,站点优化最终要回答“先动哪个站”。我的做法是把前面算出的指标汇总成一个加权评分,分越高越优先处理。案例里也建议了这套流程:断面满载率、覆盖盲区人口量、站点重复系数三个维度加权求和,权重按城市规模调整。
# 每个候选站点对应一个调整动作 candidates = pd.DataFrame({ "station_id": ["A01", "A07", "B12"], "overload_score": [0.8, 0.6, 0.4], "blind_area_score": [0.2, 0.9, 0.5], "density_score": [0.7, 0.3, 0.8] }) # 权重建议:早高峰拥挤 0.5,覆盖盲区 0.3,站间距 0.2 candidates["total_score"] = ( candidates["overload_score"] * 0.5 + candidates["blind_area_score"] * 0.3 + candidates["density_score"] * 0.2 ) print(candidates.sort_values("total_score", ascending=False))权重分配是这套模型里最需要人工干预的地方。早高峰满载率 15% 以上、盲区覆盖 2000 人以上、重复系数超 1.5,这三个阈值直接决定了哪些站进候选池。数值分不是绝对的,落地前必须现场抽验。
我从第一次做公交项目开始就吃过亏,当时纯靠数据把三个站点判了死刑,结果现场一看其中一个是医院门口,客流量虽小但全是老年人,挪走 300 米都有人反对。从那以后我每次做完数据方案,都强制走一遍“现场跟车 + 周边步行环境核查”的流程,优先保留医院、学校、老旧小区出入口周边的站点。这份案例教程的价值,其实是把这一整套从数据到决策的思路完整跑通了一遍,对照自己的数据改一改参数就能复用,希望帮到你。
本文还有配套的精品资源,点击获取