简介:这是一份以公交车站点设置为场景的Python数据分析实战案例教程,适合具备Python基础、希望通过真实项目提升数据处理与可视化能力的初学者及相关从业者。内容按完整分析流程展开:先从GPS监控数据和刷卡数据出发,讲解数据清洗、缺失值处理、降采样等预处理方法;然后使用pandas统计各站点上下车客流量,借助geopy计算站点间距,应用DBSCAN对相似站点进行聚类,并通过热力图、分布图辅助可视化;最终基于分析结果提出站点合并、增设、调整位置等具体优化建议。每一步都配有可直接运行的Python代码片段,可帮助读者掌握数据分析从问题定义到优化落地的完整思路。资源为1个PDF文件,压缩包大小约144KB,便于离线阅读和对照练习。目前已有178人学习下载,案例贴近城市公共交通场景,是学习Python数据分析实战方法的理想参考资料。
1. 公交车站点优化分析的起手式:先把“站点合理”翻译成可计算的指标
公交车站点设在哪里,直接决定乘客步行距离、线路准点率和运营成本。接手这类 Python 数据分析实战项目时,最怕一上来就写聚类、画热力图——公交车站点设置优化分析的核心,是先回答一个工程问题:“站点设置不合理”能不能量化。能量化的指标其实很少,主要就是四类:站间距、覆盖盲区、断面客流、步行可达变化。这套案例教程性质的编程实例,练的不只是 pandas 和 sklearn 的 API,而是把交通业务翻译成数据分析流程的完整能力。我按自己做过这类项目的顺序,先把数据底座讲清楚,再给指标计算和调整模拟的代码,最后把最容易翻车的坑排一遍。适合刚入门 Python 数据分析、想做有业务含义实战项目的人,也适合公交运营和线网规划岗位的同事拿去做初步诊断。
2. 建站点数据底座:刷卡数据、GPS轨迹和站点表怎么拼成一张表
2.1 先统一口径:一场分析里“一个站点”到底指什么
公交数据分析的起点往往不是一张干净的宽表。常见的数据源至少有三张:IC卡刷卡流水、GPS报站日志、站点静态档案表。IC卡流水记录的是“谁在什么时间刷了卡”,一般带线路、方向、上车站点序号;GPS报站日志记录的是“车在什么时候到达哪个站点”,会带经纬度;站点静态表是把线路、方向、站序号映射到站点名称和坐标的总表。三张表都提到“站点”,但关联键各不相同,如果不先把“线路+方向+站序号”这个复合键定下来,后面每个聚合结果都可能是错的。
这里我一般会先做一个口径约定:线路 id 加上下行方向构成一个唯一运营方向;站序号在每个方向内从首站到末站连续编号;环线要单独处理,否则首尾相接会污染排序。这个约定也要写进数据字典,因为后续所有站间距、断面客流、聚类操作的逻辑都依赖它。很多团队有现成的数据平台,导出的字段名可能已经规范化了,但这步检查仍然值得做,哪怕只是为了确认导出口径没有悄悄变化。
另一个容易忽略的点是时间范围和日期类型。做站点优化分析,数据量起码要覆盖一个完整的运营周期,也就是周一到周日、工作日和节假日都要有,否则算出来的断面客流会偏向某一种出行模式。我通常的做法是先取最近一个月的完整刷卡记录,按 weekday 字段拆开,工作日单独建一个子集,周末单独建一个子集。站点优化通常参考工作日早晚高峰,但周末和节假日的站点需求也会影响撤并决策,所以要留出分口径的余地。
2.2 用 pandas 把三张表拼成站点粒度明细:合并键与坐标清洗
下面这段是我常用的清洗起点,数据文件按实际情况替换路径就好。它的目标是输出一张站点粒度的刷卡明细宽表,后面所有指标计算都在这张表上做。
import pandas as pd import numpy as np # 刷卡表:每次刷卡一条记录,先约定必含字段 swipe = pd.read_csv('swipe_log.csv', parse_dates=['swipe_time']) # 站点静态表:线路id、方向、站序号、站名、经纬度 stops = pd.read_csv('stops_master.csv') # 1. 方向字段统一成字符串 # 不同系统里方向可能是0/1、Up/Down、上行/下行,不统一后面分组会裂成两个方向 swipe['direction'] = swipe['direction'].map( {0: 'down', 1: 'up', '上行': 'up', '下行': 'down', 'up': 'up', 'down': 'down'} ) # 2. 以(线路id, 方向, 站序号)三键合并刷卡表和站点表 # validate='many_to_one' 能顺手验证站点表没有重复键 swipe_stops = swipe.merge( stops, on=['route_id', 'direction', 'stop_seq'], how='left', validate='many_to_one' ) # 3. 坐标越界与空坐标删除 # 国内经纬度范围大致是73~135 E、3~54 N,超出基本都是脏数据 swipe_stops = swipe_stops[ (swipe_stops['lon'] >= 73) & (swipe_stops['lon'] <= 135) & (swipe_stops['lat'] >= 3) & (swipe_stops['lat'] <= 54) ] # 4. 输出合并后的空值情况,定位合并失败的具体线路和站序号 print('合并后行数:', len(swipe_stops)) print(swipe_stops[['stop_name', 'lon', 'lat']].isna().sum())这段代码里最容易出问题的是 merge 的方向统一。如果只用线路 id 合并而不管方向,上行和下行同名站点会被错误归并到一起;先 map 成统一的字符串,再按三键合并,能避免后面分组统计时把双向客流混在一个池子里。validate='many_to_one' 用来验证站点表里同一“线路+方向+站序号”没有重复行,如果有重复,pandas 会直接抛错,这比事后发现数据翻倍要省事得多。
坐标清洗我用的经纬度范围是一个大闸,不是精确边界。更严的做法是把坐标投影到所在城市坐标系,再检查与线路轨迹的距离;但在分析第一步,用经纬度范围把明显错误的点位删掉就够了,精细清洗放到后面发现异常时再做,避免一开始就陷入数据处理的无底洞,这也是实战项目里很常见的一个取舍。
2.3 按线路和方向聚合断面客流:识别全线的“压力最大段”
有了站点粒度明细表,接下来通常要做两个口径的统计:站点上下客量和断面客流。上下客量很简单,按站分组数数量就行;断面客流则要模拟车辆从起点到终点过程中,车上到底有多少人,它等于“累计上车人数减去累计下车人数”,是判断一个站点是否该保留的关键依据。
# 按线路+方向+站序号汇总上下客量 # 如果没有独立的下车记录,可以把下客量全部置0,后面再按推算口径补充 stop_flow = ( swipe_stops.groupby(['route_id', 'direction', 'stop_seq', 'stop_name', 'lon', 'lat']) .agg( board_count=('swipe_time', 'size'), # 上车刷卡次数 alight_count=('swipe_flag', lambda s: (s == 'alight').sum()) ) .reset_index() )这里要格外小心两点。一是 alight_count 的计算完全依赖“下车记录是否可靠”,很多城市的刷卡数据根本没有下车记录,只有上车记录,这时 alight_count 全是 0,断面客流就退化成“累计上车人数”,只能在局部线路上近似使用;二是时段的切分窗口不同,断面客流的峰值位置完全不同。用早高峰 6:00-9:00 算出来的最大断面,和用全天 24 小时算出来的,经常不在同一个站点区间。我一般会把最近一个季度的工作日数据都拿来做,而不是只挑某一天,否则某个临时封路或恶劣天气都会让优化建议跟着跑偏。
断面客流计算用的是累加变形公式,把上车当正数、下车当负数,从首站一路加过去,逻辑简单,但有个前置条件:所有乘客都只在刷卡站点上下车。碰到中途换乘、重复刷卡、司机强制刷卡补偿这类情况,断面会被放大。所以算完以后务必和线路配车数做交叉验证:最大断面客流除以车辆定员,应该和线路高峰发车间隔大致匹配,如果不匹配,先回查上游数据,而不是相信第一版结果。
# 断面客流:按线路+方向累加,上车正、下车负 stop_flow = stop_flow.sort_values(['route_id', 'direction', 'stop_seq']) stop_flow['cum_board'] = stop_flow.groupby(['route_id', 'direction'])['board_count'].cumsum() stop_flow['cum_alight'] = stop_flow.groupby(['route_id', 'direction'])['alight_count'].cumsum() stop_flow['section_flow'] = stop_flow['cum_board'] - stop_flow['cum_alight']这个累加逻辑要求 stop_flow 已经按正确的站序排好队。pandas 的 groupby 会保留排序后的组内顺序,但如果原始数据里有同一站重复刷卡的脏记录,组内顺序会乱掉。所以我在累加前先做了一次去重:按卡号、线路、方向、站序号、刷卡时间去掉完全重复的刷卡记录,再去累加。iOS 和 Android 的公交刷码可能产生双份流水,这也是重复记录的一个常见来源。
3. 站点布局评估的硬指标:站间距、覆盖盲区和客流不均衡怎么算
3.1 站间距异常检测:用 haversine 计算相邻站距,找出过近与过疏
站点设置是否合理,最直观的量是相邻站间距。城市常规公交的站间距没有统一的国标,但业内常用参考值是:中心城区 300~500 米,建成区 400~600 米,郊区和组团间 500~800 米。小于下限说明站点过密,车还没提速就要停;大于上限说明服务覆盖有缺口,乘客步行距离太长。这个值只是初筛用的“基线”,不同城市的规范、道路条件、线路类型都不太一样,所以一开始不要拿单个阈值卡死,而是把整条线路的站距分布画出来先看一眼。
实际计算时有个排序陷阱:如果直接用站点在整个城市层面的经纬度排相邻关系,两条线路交叉处的站点会互相干扰;必须按“线路+方向+站序号”分组,站序号本身代表线路走向。下面这段代码用的是滞后一行的 shift,把站序号连续的记录串起来。
import numpy as np def haversine(lon1, lat1, lon2, lat2): """两经纬度点之间的球面距离,返回米。""" R = 6371000.0 phi1, phi2 = np.radians(lat1), np.radians(lat2) dphi = np.radians(lat2 - lat1) dlambda = np.radians(lon2 - lon1) a = np.sin(dphi / 2.0) ** 2 + np.cos(phi1) * np.cos(phi2) * np.sin(dlambda / 2.0) ** 2 return 2 * R * np.arcsin(np.sqrt(a)) # 站点表按线路、方向、站序号排序 stops_sorted = stops.sort_values(['route_id', 'direction', 'stop_seq']).copy() stops_sorted['prev_lon'] = stops_sorted.groupby(['route_id', 'direction'])['lon'].shift(1) stops_sorted['prev_lat'] = stops_sorted.groupby(['route_id', 'direction'])['lat'].shift(1) # 首站没有前序站,dropna 会顺带把环线首尾接缝处的脏排序一起过滤 stops_sorted = stops_sorted.dropna(subset=['prev_lon', 'prev_lat']) stops_sorted['dist_to_prev'] = haversine( stops_sorted['prev_lon'].values, stops_sorted['prev_lat'].values, stops_sorted['lon'].values, stops_sorted['lat'].values ) # 先用宽松阈值初筛:低于200米或高于1200米都标记出来人工复核 stops_sorted['flag'] = np.where( (stops_sorted['dist_to_prev'] < 200) | (stops_sorted['dist_to_prev'] > 1200), 'check', 'ok' ) # 输出每个方向的站距统计和异常站点 print(stops_sorted.groupby(['route_id', 'direction'])['dist_to_prev'].describe()) print(stops_sorted[stops_sorted['flag'] == 'check'][['route_id', 'direction', 'stop_seq', 'stop_name', 'dist_to_prev']])这段代码里的 shift 分组是关键。如果不按线路方向分组直接 shift,边界处会拿到别的线路的站点坐标,算出的距离上千公里,一开始看到这个数字别慌,基本是边界脏数据。另外 haversine 函数我直接用 numpy 实现了向量写法,不再逐行 apply,几万站点的数据跑起来也很快。
值得一说的是,过近的两站不一定都该撤。有一种典型情况是“复设站”:同一位置为了分流不同线路停靠,设了两个相距 50 米的站台,这在市区很多见。站间距指标会把它标记成异常,但真实工况恰恰需要这个复设站。所以我把“距 200 米以下”标记为待复核,而不是直接删除,这类业务判断交给分析结果上的人工决策,这也是这类实战项目跟纯粹调参最大的差别。
3.2 覆盖盲区评估:500米缓冲区叠加人口网格,算真正服务到的面积
站间距只能反映线路内部的均匀度,覆盖盲区要回答的问题是:设了这么多站,是不是把沿线居住区、办公区都接进了可接受步行范围。“步行 500 米到站”几乎成了各大城市公交线网评估的默认口径,但实际计算时第一个坑就是坐标系。经纬度是角度单位,直接对它做 buffer(500),得到的不是 500 米,而是 500 度,放大地图能看到一个巨大的圆甚至覆盖全省。
常见做法是先把 GeoDataFrame 转成以米为单位的投影坐标系,再做半径 500 米的缓冲区,最后与人口小区网格做叠加差集。
import geopandas as gpd from shapely.geometry import Point # 将站点表转成GeoDataFrame gdf_stops = gpd.GeoDataFrame( stops_sorted[['route_id', 'stop_seq', 'stop_name', 'lon', 'lat']], geometry=gpd.points_from_xy(stops_sorted['lon'], stops_sorted['lat']), crs='EPSG:4326' ) # 投影到Web墨卡托,缓冲距离才能按米算 gdf_stops = gdf_stops.to_crs('EPSG:3857') gdf_buf = gdf_stops.copy() gdf_buf['geometry'] = gdf_buf.geometry.buffer(500) # 人口/居住小区网格面要素,同样转投影 grid = gpd.read_file('residential_grid.shp').to_crs('EPSG:3857') # 用sjoin标记网格是否被任意站点缓冲区覆盖 joined = gpd.sjoin(grid, gdf_buf[['geometry']], how='left', predicate='intersects') joined['covered'] = joined['index_right'].notna() # 没有被任何站点缓冲区覆盖的网格 blind = joined[~joined['covered']] blind_area = round(blind.geometry.area.sum() / 10000, 2) total_area = round(grid.geometry.area.sum() / 10000, 2) print(f"研究范围总面积:{total_area} 公顷") print(f"没有站点覆盖的面积:{blind_area} 公顷,盲区占比 {blind_area / total_area * 100:.2f}%")这段代码的覆盖率口径是“面积覆盖率”。但实际工作中更常用的不是面积,而是“人口覆盖率”——某个网格里哪怕只有 100 平方米被缓冲区照亮,也应该按整个网格的人口算受惠人口,还是只按被覆盖的 100 平方米算?两种口径相差悬殊。我在做这类项目时通常会同时算两个版本:面积覆盖率用来和上级规划文件的口径对齐,人口覆盖率用来判断这个盲区到底重不重要。同一个盲区,如果 500 米内根本没有居住人口,优化优先级就大幅降低。
另外,sjoin 的 predicate 参数在不同版本的 geopandas 里有两种写法:旧版用 op='intersects',新版用 predicate='intersects'。如果报错说 op 参数不存在,把参数名改成 predicate 就好了。这种兼容性问题在真实项目里耗掉的时间一点不比调模型少。
表:两种覆盖口径的适用场景
| 口径 | 计算方式 | 适用场景 |
|---|---|---|
| 面积覆盖率 | 缓冲区面积 / 研究范围面积 | 与政府规划文件、线网规划标准对齐 |
| 人口覆盖率 | 被覆盖网格人口 / 总人口 | 判断盲区对实际出行的影响程度 |
3.3 客流不均衡度:用最大断面/平均断面找出全线的“拥堵咽喉”
除了空间上的站间距和覆盖,还要看客流在时间上的分布。一条线路日均客流不大,不代表没有压力;下午五点的车辆可能在某个区间段内爆满,这个区间的两端站点就是典型的“不该撤但需要优化停靠”的点。量化不均衡最常用的指标是“方向不均衡系数”和“断面不均衡系数”。前者比较同一线路上下行总客流差异,后者看同一方向内各断面客流的离散程度。
# 接2.3节算出的断面客流,按线路方向求最大断面与平均断面 section_agg = ( stop_flow.groupby(['route_id', 'direction']) .agg( max_section=('section_flow', 'max'), mean_section=('section_flow', 'mean') ) .reset_index() ) section_agg['section_imbalance'] = section_agg['max_section'] / section_agg['mean_section'] # 通常认为>=2.0属于强不均衡,需要单独看这些线路 hot_lines = section_agg[section_agg['section_imbalance'] >= 2.0].sort_values( 'section_imbalance', ascending=False )这个系数达到 2 以上,说明最大断面客流是平均值的两倍,该区段是整条线的咽喉。对这种线路做站点优化时,撤站优先级应该从咽喉区段之外去找,而不是在最大断面上做减法,否则会让本已饱和的区段更糟。很多规划团队会把这个系数和配车数结合,算出车辆满载率曲线,那才是站点设置优化真正要服务的运营决策。
三个指标算完,通常还要横向看它们之间的交叉作用:一个站站距正常、覆盖也正常,但断面客流是全线的最大断面,那这个站绝对不能被撤;反过来,一个站覆盖盲区很大但客流量极低,撤站的影响就相对有限。这种交叉判断正是这种“案例教程编程实例”里最有价值的部分——代码只是工具,怎么把指标组合成业务结论才是经验所在。
4. 用聚类和OD模拟站点撤并:把候选方案算成可对比的距离与客流
4.1 用 KMeans 对站点做初步“撤并分组”,轮廓系数选 K
当线路上的问题站点锁定之后,下一步是生成候选调整方案。常见做法是把间距过小、客流又不高的站点聚成一个组,每组内保留一个,其余撤掉。这里的 KMeans 不是传统意义的聚类挖掘,而是当作一个分组工具来用,所以在特征里同时放上经纬度和上下客量,并做标准化,否则经纬度坐标的数值尺度会把客流信息完全吃掉。
from sklearn.cluster import KMeans from sklearn.preprocessing import StandardScaler from sklearn.metrics import silhouette_score # 候选站点:前面被标记为check、且上下客量较低的站点 candidate = stops_sorted[stops_sorted['flag'] == 'check'].copy() candidate = candidate.merge( stop_flow[['route_id', 'direction', 'stop_seq', 'board_count']], on=['route_id', 'direction', 'stop_seq'], how='left' ).dropna(subset=['board_count']) # 特征:经纬度 + 上下客量,标准化后聚类 X = candidate[['lon', 'lat', 'board_count']].values X_scaled = StandardScaler().fit_transform(X) # 用轮廓系数粗选K,通常2~6之间就够了 sil_scores = {} for k in range(2, 7): km = KMeans(n_clusters=k, n_init=10, random_state=42).fit(X_scaled) sil_scores[k] = silhouette_score(X_scaled, km.labels_) best_k = max(sil_scores, key=sil_scores.get) km = KMeans(n_clusters=best_k, n_init=10, random_state=42).fit(X_scaled) candidate['cluster'] = km.labels_ # 每组内优先保留客流最大的站点,作为撤并后的“留下来” kept = ( candidate .sort_values('board_count', ascending=False) .groupby('cluster') .head(1) .reset_index(drop=True) ) dropped = candidate[~candidate.index.isin(kept.index)] print(f"最佳K={best_k},候选{len(candidate)}站分为{best_k}组,拟撤{len(dropped)}站")轮廓系数在这里只是参考,不要把它当成黑匣子里的真理。K 从 2 变到 3,轮廓系数可能只差 0.02,这时应该结合实际运营判断:这个簇的两站是否隔着一条无红绿灯的高架路、是否跨过行政区边界。如果 KMeans 把两个相距 100 米但位于立交桥两侧的站分到同一组,结论就会很荒谬。我一般会先用轮廓系数圈定 K 的范围,再手动看 2~4 个方案的分组结果,最后才敲定参数。n_init=10 是为了避免初始中心随机导致的标签抖动,random_state 固定住,方便复现。
聚类特征里加 board_count 还是加“上下客总量”,要看数据的完整度。有下车记录时,建议用“上客量+下客量”两个特征一起聚类;没有下车记录时,只用上客量也能跑,但要明白少了一个维度的信息。另一个容易被忽略的细节是:如果一条线路站点本身很长,经纬度的量纲差异会主导聚类,把小客流站点和相邻大客流站点分到一组;我自己通常会把特征改成“标准化后的经纬度差”,这个做法在短站距场景下比直接标准坐标更稳健,但这属于可自由选择的优化点,数据量小的时候差别不大。
4.2 撤并后的 OD 重放:用最近邻重新分配客流,算“多走的距离”
聚类只是给出“哪些站可以一起考虑”,真正决定方案好坏的指标是乘客步行距离的变化。这就用到 OD 重放:把被撤站点的上下客客流,按照某种分配规则转移到最近的保留站点,再计算转移后的乘客平均步行距离增量。现实里乘客可能改乘别的线路、改成骑车、甚至放弃公交,做不到精确模拟,但用最近邻做一版保守估计是完全够用的。
from scipy.spatial import cKDTree # 保留站点坐标树;被撤站点坐标 # 注意:正式项目里先把经纬度投影成米制坐标,再进cKDTree,距离才是米 kept_coords = kept[['lon', 'lat']].values drop_coords = dropped[['lon', 'lat']].values tree = cKDTree(kept_coords) # k=1 找最近保留站 dist_m, idx = tree.query(drop_coords, k=1) # 被撤站客流 = 上车 + 下车(无下车记录时只算上车近似) dropped['transfer_count'] = dropped['board_count'] + dropped['alight_count'] # 被撤乘客新增步行距离:被撤站到最近保留站的距离 added_distance = (dropped['transfer_count'] * dist_m).sum() # 原平均步行距离用350米做基线假设,只用于相对比较 baseline_distance = (candidate['board_count'] * 350).sum() pct_increase = added_distance / baseline_distance * 100 print(f"被撤客流共需转移到{kept.shape[0]}个保留站") print(f"新增步行距离合计:{added_distance / 1000:.1f} 公里") print(f"相比原状态步行距离增加比例:{pct_increase:.2f}%")cKDTree 的 query 返回的是两点间欧氏距离,单位取决于入参坐标。这里用的经纬度是角度,算出来的是“度”,不是米。要得到米级距离,把坐标先投影到以米为单位的局部坐标系再做 cKDTree。实战中我会用 pyproj 把经纬度转成城市对应的 UTM 投影坐标,这样距离单位才是米。上面代码里 dist_m 的变量名只是示意,真正落地时务必先投影。
OD 重放的合理性取决于对“原步行距离”的假设。我默认取 350 米代表乘客原来的平均步行距离,这是一个很粗糙的估计。更严谨的做法是用人口网格重心到原站点的真实路网距离。但在这个步骤里,我们的目标是“方案之间的相对比较”,不是算绝对社会效益,所以只要所有方案用同一个基线假设,相对优劣就是可信的。这点很重要:做模拟分析时,绝对精度可以放松,口径一致性必须保证。
另外,OD 重放要区分“被撤站点客流转移”和“保留站点客流自然增长”两种效果。被撤站的客流转移到保留站后,保留站的上下客量会变大,可能突破站台容纳能力。很多分析只算步行距离,不算保留站的新增压力,结果方案看似优化,实施后却把拥堵转移到了另一个站。我在评估步骤里会额外输出保留站点调整前后的客流变化率,超过 30% 的站点需要单独复核。
4.3 模拟方案的边界条件:首末站、高架隔离和线路调整范围
OD 重放结果不是一个可以直接拍板的值,它还需要配合三个边界条件来判断。
第一,首末站原则上不能撤。公交首末站承担车辆停放、调头、发班排班,把客流不大但紧挨起点站的站点撤掉,可能省了停靠时间,但会让首段线路的乘客被迫走出几十米甚至几百米,而且破坏发班间隔。所以做候选时,stop_seq 为首站或末站的站点要直接排除。第二,立交桥、高架、河流是天然屏障,最近邻站在地图上看着只差 300 米,实际要绕行一个匝道,步行 15 分钟。这种情况下要引入道路网络距离,而不是继续用直线距离。第三,调整范围不要只局限在一条线路。很多站点是多条线路共用的,撤掉影响的不只是一条线。如果手头有多线路数据,做撤并前先统计该站每日服务线路数,多线共用站优先保留。
这三个边界条件通常写成分析文档里的“约束条款”,代码只是执行工具。很多刚入行的同事做完聚类和重放就急着看结论,结果连首末站都被算法撤了,这种翻车我见过不止一次。先列好约束再跑模型,比跑完再手工修正要稳得多。第一次跑完看分组结果时,也不要急着接受 best_k 的建议,把每个簇的站点拉到地图上对照道路结构过一遍,这一步花的时间不会超过二十分钟,但能挡住一大半不合理的方案。
5. 站点优化分析常踩的五个坑:现象、原因和解决办法
5.1 刷卡数据里“下车记录”全为空,断面客流变成纯上车曲线
现象:按前面代码算断面客流,最大断面出现在线路后半段,而且数值高得离谱,超过车辆定员好几倍。
原因:很多城市的公交刷卡系统只记录上车,不记录下车。下车数据要么靠司机人工按铃采集,要么根本没有,导致累加公式里 alight 恒等于 0,断面客流退化为累计上车人数。
解决:先用数据字典确认下车记录字段是否存在;实在没有时,常见做法是用“同线路反向对称假设”估计下客量:统计该站在后续站点的上车人数分布,按比例推算下客点。如果连这个都做不了,就不要输出绝对断面客流,只输出相对排名,把结论定位成“站点客流强度排序”而不是“精确满载率”。这个坑是这类项目里最常见的,一开始就确认字段口径,后面能少走很多弯路。
5.2 GPS 坐标漂移让相邻站距算成 2 公里
现象:跑出来的站间距列表里,连续三四个站的距离都超过 1500 米,但司机和乘客都知道这两站走路只要 5 分钟。
原因:报站日志里的坐标不是报站器标定的坐标,而是车辆 GPS 瞬时定位,在隧道、高架下方、高楼遮挡区容易漂几百米甚至更远。漂移距离直接污染相邻站距,这是我们早期项目踩过的血泪经验。
解决:第一步把原始 GPS 轨迹按相邻点速度过滤,速度超过 80 km/h 的跳变点先剔除;第二步用“站点静态表里的规划坐标”而非“GPS 实时坐标”去算站距。站点静态表的坐标来自线路标定,漂移问题少很多。如果只有 GPS 坐标,把它按线路轨迹做投影校正也行,但工作量大,非必要不折腾。肉眼看到一个站距异常时,不要急着改阈值,先怀疑数据源。
5.3 环线线路的站序号逻辑和其它线路完全不同
现象:一条环线跑了几个来回,站序号从 0 走到 15,然后突然回到 0。按 groupby 排序算间距时,首尾两点之间被算出一条上千公里的距离。
原因:环线没有真正意义的“首末站”,站序号为了回库方便可以循环编号,甚至同一个物理站会在表中出现两次,进环和出环的编号不同,排序逻辑天然不连续。
解决:在清洗阶段对线路类型做区分。环线单独建一个分组键,把循环序列拆成两段,各加分段序号,或者直接用“到站先后时间”排序,而不是用站序号排序。核心是一条原则:站序号之间的顺序,只在线路类型一致时才有比较意义。如果你分析的城市里有大量环线,这一步不做,后面的站间距和断面客流全是废的。
5.4 “500 米覆盖”算出来 95%,实际乘客却反映要走 20 分钟
现象:缓冲区分析显示 95% 的人口都被覆盖,和居民投诉“公交不便”的报告互相矛盾。
原因:缓冲区分析用的是直线距离,而乘客走的是路网。城市里受封闭小区、快速路、河流、铁路切割,直线 500 米的路可能绕出 1.5 公里。另外,缓冲区覆盖的是“居住网格”,不是“小区出入口”,这两个点的位置差经常被忽略。
解决:如果人口网格精度不足,先不纠结覆盖率绝对值,改成“比较方案前后覆盖率变化”的相对口径。要做绝对可达性评估,就引入道路网数据,算各网格重心沿路网的 500 米可达范围,这一步需要预处理城市路网数据,工作量不小,但结论能直接支撑撤站决策,值得做。做相对比较时,也要固定同一个路网和同一个网格边界,换一套底图,结果差异会大到无法解释。
5.5 用不均衡系数评价站点分布时,把不同方向混在一起算
现象:两个方向客流严重不对称的线路,算出来的不均衡系数却很低,分析结论说“整体均衡”。
原因:不均衡系数必须按运营方向分开算。上下行客流可以差到 40% 以上,合在一起算时,高断面和低断面的数值互相抵消,系数自然好看,但这掩盖了单向拥堵。
解决:所有不均衡指标都在“线路+方向”粒度上计算,展示时再把上下行并排输出,看方向差异本身也是一个重要结论。这一点在代码实现上很容易漏,因为 groupby 里少写一个刻度,结论就会完全反转。我每次跑完指标都会先打印一下分组行数,确认每个组都是预期粒度,再往下看数字。方向不均衡本身就是线路设计的重要输入,比如上下行客流差异巨大的线路,往往说明沿线职住分布错位,而站点加密方向也应该偏向客流大的那一侧。
6. 用 OD 回放验证方案:一个能说服自己和甲方的最小检查流程
最后的最后,给你一个我一直保留的验证习惯:任何站点调整方案,不要停留在“指标变好看了”,而是把改前改后两套方案放在同一个 OD 表上各跑一遍,输出同一口径的对比结果。这一步不需要什么复杂框架,把前面几章的代码串成一个评估函数就够了。
def evaluate_plan(stop_df, od_df, walk_assume=350): """给定站点方案和OD表,返回全线平均步行距离。""" coords = stop_df[['lon', 'lat']].values tree = cKDTree(coords) _, idx = tree.query(od_df[['lon', 'lat']].values, k=1) od_df['assigned_stop'] = idx od_df['walk_dist'] = walk_assume return od_df['walk_dist'].mean() base_score = evaluate_plan(original_stops, od_df) new_score = evaluate_plan(new_stops, od_df) print(f"调整前平均步行距离:{base_score:.1f} 米") print(f"调整后平均步行距离:{new_score:.1f} 米") print(f"变化幅度:{new_score - base_score:+.1f} 米({(new_score - base_score) / base_score * 100:+.2f}%)")这个函数把“方案评估”从一次性脚本变成了可复用工具。换方案、换阈值、换聚类 K 值,都只需要重新喂入一组站点坐标。我在项目里通常还会加一个参数,允许传入站点权重的增量结果,比如“新增站点的步行距离增量是 80 米,但覆盖人口增加 1200 人”,用这种两个维度同时输出的方式给决策层看,比单一指标更有说服力。
验证时还要固定两个东西:一个是 OD 表不能动,前后对比必须用同一批乘客数据,否则差异来源分不清是方案变化还是客流自然波动;另一个是路网口径,如果用了路网距离,前后两次必须用同一版路网。改动任何一个,结论就不可比了。这也是为什么我在前面强调“相对比较优先于绝对精度”——对一次站点优化评估来说,两个方案之间差出的 50 米步行距离,比单一方案的绝对数值更值得决策者关注。
我的习惯是每次跑完方案,都会把“基线假设、约束条件、输入数据版本”三项写进报告附注,哪怕这次只是自己在复盘。这招救过我很多次,因为方案评审会上总会有人问“这个 350 米哪来的”“这个撤站有没有排除首末站”,能当场答上来,项目推进就顺畅得多。这一套流程走完,你会发现自己对“数据分析项目实战”的理解已经不只是跑通代码,而是能把任何业务问题翻译成指标、约束和可验证的模拟。希望帮到你。
本文还有配套的精品资源,点击获取