简介:这份PDF教程面向具备一定Python基础、希望用数据分析解决实际问题的学习者,以公交站点设置优化为完整案例,串联数据清洗、客流量统计、站点间距计算、DBSCAN聚类与可视化等环节,帮助读者掌握从原始数据到优化建议的完整分析链路。资源包内仅含1个PDF文件,大小约144KB,内容以图文与代码示例为主,涵盖GPS监控数据与刷卡数据的预处理、站点客流量分析、站点间距离计算、聚类建模及热力图、聚类分布图等可视化实现,并给出合并低流量站点、增设高流量站点、调整站点位置等具体优化策略。目前已有178人学习,适合想通过真实城市交通场景练习pandas、geopy、scikit-learn与matplotlib等工具的数据分析学习者参考。
1. 公交站点优化这件事,为什么值得用 Python 从头跑一遍
早高峰在小区门口等车,眼看着三辆车连着进站,下一趟却要再等十五分钟——这种体验背后其实是站点设置和线路调度的问题。这份《Python数据分析实战,公交车站点设置优化分析,案例教程编程实例课程详解.pdf》就是拿真实公交刷卡与 GPS 数据,走一遍从数据清洗、客流统计到站点合并建议的完整链路。它适合两类人:一类是刚学完 Python 基础语法、想找一个有业务背景的数据分析项目练手的;另一类是做交通、规划、运营相关工作,需要一套能直接套到自己城市数据上的分析框架的。整份材料以 PDF 教程形式组织,配套代码实例,核心不是讲 pandas 语法,而是讲怎么把一堆杂乱的上下车记录变成「哪个站该合并、哪个站该挪位置」的决策依据。下面我按自己复现时的顺序,把关键步骤和踩过的坑拆开讲。
2. 数据从哪来、长什么样:先把公交 IC 卡与 GPS 两张表对齐
2.1 公交数据的两个核心来源
公交站点优化分析,本质上要回答两个问题:人在哪上车、在哪下车,以及车实际停在哪。前者来自 IC 卡刷卡记录,后者来自车载 GPS 轨迹。这两份数据在大多数城市的公交系统里是分开存储的,字段命名和采集频率都不一样,所以第一步不是写分析代码,而是搞清楚手里有什么。
常见的 IC 卡数据字段包括:卡号(或脱敏后的卡 ID)、刷卡时间戳、线路编号、车辆编号、上下车标志、站点编号。注意很多城市的刷卡机只记录上车,下车靠乘客二次刷卡或干脆没有,这时候就得用「出行链推断」的方法补全下车站点——常见做法是按同一张卡当天相邻两次上车记录,把第二次上车的前一站当作第一次的下车站。这个推断有误差,但比直接丢掉一半数据强。
GPS 数据一般是车辆每隔 10 到 30 秒上报一次经纬度、瞬时速度、方向角和线路编号。它的作用是确定每个站点的真实坐标,以及车辆在两个站点之间的行驶时间。把 GPS 轨迹和站点编号匹配上,才能算出站间距和区间运行时长。
2.2 用 pandas 做字段对齐与时间戳统一
拿到数据后第一件事是统一时间格式。IC 卡系统常用的是「YYYYMMDDHHMMSS」这种紧凑字符串,GPS 可能是 Unix 时间戳或带毫秒的 ISO 格式。不统一的话,后面按时间窗口聚合会直接错位。
import pandas as pd import numpy as np # 读取 IC 卡刷卡记录,假设是 CSV,字段名按实际调整 ic = pd.read_csv("ic_card.csv", dtype={"card_id": str, "line_id": str, "stop_id": str}) # 时间戳统一:把紧凑字符串转成标准 datetime ic["swipe_time"] = pd.to_datetime(ic["swipe_time"], format="%Y%m%d%H%M%S") # 读取 GPS 轨迹 gps = pd.read_csv("gps_track.csv", dtype={"line_id": str, "vehicle_id": str}) gps["report_time"] = pd.to_datetime(gps["report_time"], unit="s") # Unix 秒级时间戳 # 按线路和车辆分组,检查时间范围是否覆盖同一天 print(ic.groupby("line_id")["swipe_time"].agg(["min", "max"]).head()) print(gps.groupby("line_id")["report_time"].agg(["min", "max"]).head())这段代码做了三件事:指定字符串类型防止卡号被读成科学计数法、把两种时间格式统一成 pandas 的 datetime、按线路检查两份数据的时间覆盖范围。参数说明:dtype里把 ID 类字段强制设为str是血泪经验,卡号一旦被当成数字,前导零会丢,后面关联全乱。unit="s"表示 GPS 时间是秒级 Unix 时间戳,如果是毫秒就改成unit="ms"。
2.3 站点坐标匹配与站间距计算
GPS 点本身不是站点,需要把轨迹点聚类或按到站减速特征识别出站点位置。简单做法是:对每条线路,取所有 GPS 点中速度低于某个阈值(比如 5 km/h)且持续超过 10 秒的点簇,簇中心就是站点坐标。然后把识别出的坐标和 IC 卡数据里的站点编号做最近邻匹配。
from scipy.spatial import cKDTree # 假设 stops 是站点编号与经纬度的对照表 stops = pd.read_csv("stops.csv") # 字段:stop_id, lat, lon # 把 GPS 低速点聚成候选站点 slow = gps[gps["speed"] < 5].copy() slow["lat_r"] = slow["lat"].round(4) slow["lon_r"] = slow["lon"].round(4) candidates = slow.groupby(["line_id", "lat_r", "lon_r"]).size().reset_index(name="cnt") candidates = candidates[candidates["cnt"] > 3] # 至少出现 3 次才算稳定站点 # 用 KDTree 做最近邻匹配 tree = cKDTree(stops[["lat", "lon"]].values) dist, idx = tree.query(candidates[["lat_r", "lon_r"]].values, k=1) candidates["matched_stop"] = stops.iloc[idx]["stop_id"].values candidates["dist_deg"] = dist逻辑说明:先按经纬度四舍五入到小数点后四位(约 11 米精度)做粗聚类,再过滤掉出现次数太少的候选点,避免把等红灯的位置误判成站点。KDTree 查询返回的是欧氏距离(单位是度),实际工程里要转成米再设阈值,一般超过 50 米就认为匹配不可靠,需要人工核对。这一步是整个分析的地基,站点坐标错了,后面所有客流归属都会偏。
3. 客流统计与站点热度:从刷卡记录算出每个站的上下客量
3.1 按时间窗口聚合上下客量
有了对齐好的数据,下一步是统计每个站点在每个时间段的上车人数和下车人数。上车直接按刷卡记录的站点编号分组计数即可;下车如果数据里有下车站点字段就直接用,没有就用 2.1 说的出行链推断补。
# 上车量:直接按站点和小时聚合 ic["hour"] = ic["swipe_time"].dt.hour board = ic.groupby(["stop_id", "hour"]).size().reset_index(name="board_cnt") # 下车量:如果有下车站点字段 if "alight_stop_id" in ic.columns: alight = ic.groupby(["alight_stop_id", "hour"]).size().reset_index(name="alight_cnt") alight.rename(columns={"alight_stop_id": "stop_id"}, inplace=True) else: # 出行链推断:同一张卡按时间排序,下一次上车的前一站视为本次下车 ic_sorted = ic.sort_values(["card_id", "swipe_time"]) ic_sorted["next_stop"] = ic_sorted.groupby("card_id")["stop_id"].shift(-1) alight = ic_sorted.dropna(subset=["next_stop"]).groupby(["next_stop", "hour"]).size().reset_index(name="alight_cnt") alight.rename(columns={"next_stop": "stop_id"}, inplace=True) # 合并上下客量 flow = pd.merge(board, alight, on=["stop_id", "hour"], how="outer").fillna(0) flow["total"] = flow["board_cnt"] + flow["alight_cnt"]参数说明:dt.hour提取小时用于分时段统计,如果想看早晚高峰更细的粒度,可以改成dt.floor("15min")按 15 分钟聚合。出行链推断里shift(-1)取的是同一张卡的下一次上车记录,dropna丢掉当天最后一次上车(因为没有下一次记录,无法推断下车站)。这个推断对通勤乘客准确率较高,但对一天只刷一次卡的乘客无效,实际项目中通常只对推断置信度高的记录做分析。
3.2 站点热度排序与可视化
统计出每个站点的总客流后,可以按总量排序找出 Top N 热点站,也可以按小时看客流分布形态。常见做法是画一张「站点-小时」的热力图,横轴是小时,纵轴是站点,颜色深浅代表客流量。
import matplotlib.pyplot as plt import seaborn as sns # 取客流前 20 的站点 top_stops = flow.groupby("stop_id")["total"].sum().nlargest(20).index pivot = flow[flow["stop_id"].isin(top_stops)].pivot_table( index="stop_id", columns="hour", values="total", fill_value=0 ) plt.figure(figsize=(14, 8)) sns.heatmap(pivot, cmap="YlOrRd", linewidths=0.5) plt.title("Top 20 站点分时客流热力图") plt.xlabel("小时") plt.ylabel("站点编号") plt.tight_layout() plt.savefig("stop_heatmap.png", dpi=150)逻辑说明:nlargest(20)取总客流最大的 20 个站点,pivot_table把长表转成「站点×小时」的宽表,fill_value=0把没有记录的时段填零。热力图能一眼看出哪些站是早高峰单峰型(住宅区)、哪些是晚高峰单峰型(商业区)、哪些是全天平稳型(医院、枢纽)。这个形态分类对后面判断站点该不该合并很关键——两个都是早高峰单峰的站,合并后可能加剧拥挤;一个早高峰一个晚高峰的站,合并反而能平衡运力。
3.3 站间距与重复站点识别
站点优化的核心动作之一是合并距离过近的站点。一般城市公交站间距在 500 到 800 米比较合理,低于 300 米的相邻站就值得考虑合并。用前面匹配好的站点坐标,计算同一条线路上相邻站点的实际距离。
from math import radians, sin, cos, sqrt, atan2 def haversine(lat1, lon1, lat2, lon2): R = 6371000 # 地球半径,单位米 dlat = radians(lat2 - lat1) dlon = radians(lon2 - lon1) a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 return R * 2 * atan2(sqrt(a), sqrt(1-a)) # 按线路和站点顺序计算相邻站间距 stops_sorted = stops.sort_values(["line_id", "stop_seq"]) stops_sorted["next_lat"] = stops_sorted.groupby("line_id")["lat"].shift(-1) stops_sorted["next_lon"] = stops_sorted.groupby("line_id")["lon"].shift(-1) stops_sorted["dist_m"] = stops_sorted.apply( lambda r: haversine(r["lat"], r["lon"], r["next_lat"], r["next_lon"]) if pd.notna(r["next_lat"]) else np.nan, axis=1 ) # 找出间距小于 300 米的相邻站对 close_pairs = stops_sorted[stops_sorted["dist_m"] < 300][["line_id", "stop_id", "next_stop_id", "dist_m"]]参数说明:haversine公式算的是球面距离,比直接用经纬度差乘系数准确,尤其在纬度较高的城市。stop_seq是站点在线路上的顺序号,没有这个字段的话需要按 GPS 轨迹的先后顺序自己排。shift(-1)取下一站,最后一站没有下一站所以结果是 NaN,用pd.notna过滤掉。300 米这个阈值不是固定的,核心城区可以放宽到 250 米,郊区可以放到 400 米,要看当地居民的步行习惯和道路条件。
4. 优化建议怎么落地:合并方案、仿真验证与常见翻车点
4.1 站点合并的决策逻辑
识别出距离过近的站点对之后,不能直接说「合并」。还要看两个站的客流是否互补、合并后新站的位置是否方便两边乘客。我一般会按下面这个优先级判断:
| 判断维度 | 合并倾向 | 不合并倾向 |
|---|---|---|
| 站间距 | 小于 300 米 | 大于 500 米 |
| 两站客流之和 | 合并后不超过单站最大容量 | 合并后严重超载 |
| 客流时间分布 | 一个早高峰一个晚高峰 | 都是早高峰 |
| 周边用地 | 同一片区、无隔断 | 被主干道或河流隔开 |
| 换乘需求 | 无其他线路换乘 | 是重要换乘节点 |
这张表不是拍脑袋定的,是复现多个城市案例后总结的经验值。实际项目中,我会把每个维度量化成分数,加权求和后排序,把得分最高的前几对作为优先合并候选,再交给规划人员人工确认。
4.2 用仿真思路验证合并效果
合并方案提出后,怎么知道它好不好?最直接的办法是做一次简单的客流重分配仿真:假设原来在 A 站上车的人,合并后走到新站 C 上车,步行距离增加,部分乘客可能流失。可以用一个简化的 Logit 模型估算流失率。
import numpy as np def simulate_merge(flow_a, flow_b, walk_extra_min, beta=-0.1): """ flow_a, flow_b: 合并前两站各自客流 walk_extra_min: 合并后平均多走的步行时间(分钟) beta: 步行时间敏感系数,负值表示时间越长客流流失越多 """ total = flow_a + flow_b retention = np.exp(beta * walk_extra_min) # 简化保留率 return total * retention # 示例:A 站 500 人,B 站 300 人,合并后平均多走 3 分钟 new_flow = simulate_merge(500, 300, 3) print(f"合并后预计客流:{new_flow:.0f} 人,流失 {800 - new_flow:.0f} 人")参数说明:beta是步行时间敏感系数,取值一般在 -0.05 到 -0.15 之间,绝对值越大表示乘客越不愿意多走路。这个值需要根据当地调研数据标定,没有调研数据时可以用 -0.1 做敏感性分析。retention是保留率,exp(beta * walk_extra_min)在步行增加 3 分钟、beta=-0.1 时约为 0.74,意味着约四分之一乘客会流失。这个模型很粗糙,但能快速筛掉那些「合并后客流掉太多」的方案,避免在明显不合理的方案上浪费时间。
4.3 避坑与常见问题排查
现象一:刷卡记录里同一张卡同一时间出现多条记录。原因通常是刷卡机重复上传或数据同步延迟。解决方法是按card_id + swipe_time去重,保留第一条,同时记录去重数量用于评估数据质量。
现象二:GPS 轨迹漂移导致站点坐标偏移几百米。原因是城市高楼区信号反射。解决方法是不要用单点定位,而是取车辆停靠期间多个 GPS 点的均值,或者用地图匹配算法把轨迹吸附到路网上再提取站点。
现象三:出行链推断的下车站点大量落在终点站。原因是很多乘客当天只刷一次卡,shift(-1)取不到下一次记录,被错误地归到了线路终点。解决方法是对只刷一次卡的记录单独标记,不参与下车站推断,或者用历史出行规律做补充推断。
现象四:合并方案在数据上成立,实际执行被否决。原因往往是忽略了道路物理隔离、小区出入口位置、老年人步行能力等数据里看不到的因素。解决方法是把数据分析结果作为输入而不是结论,最终方案一定要结合实地踏勘。
现象五:不同线路的站点编号体系不统一。同一物理站点在不同线路上可能有不同编号,直接按编号合并会漏掉跨线路的重复站。解决方法是先用坐标做空间聚类,给每个物理站点分配统一 ID,再按统一 ID 聚合客流。
5. 把分析脚本变成可复用的工具:参数化与结果校验
5.1 用配置文件管理分析参数
每次换一个城市的数据,站间距阈值、步行敏感系数、时间窗口粒度这些参数都要改。如果全写在代码里,改一次就要翻一遍脚本。我习惯把参数抽到一个 YAML 或 JSON 文件里,代码只读配置。
import yaml with open("config.yaml", "r", encoding="utf-8") as f: cfg = yaml.safe_load(f) # config.yaml 内容示例: # merge_distance_m: 300 # walk_beta: -0.1 # time_window: "15min" # top_n_stops: 20 merge_threshold = cfg["merge_distance_m"] beta = cfg["walk_beta"]逻辑说明:yaml.safe_load比eval安全,不会执行任意代码。配置文件里还可以放数据路径、输出目录、线路白名单等。这样换项目时只改配置不改代码,也方便把不同城市的参数存档对比。
5.2 结果校验:三个必须做的检查
分析结果出来之后,别急着出报告。我每次都会强制走一遍这三个检查:
第一,总量守恒检查。合并前后的总客流应该基本一致(考虑流失率后允许小幅下降),如果合并后总客流反而增加,说明统计逻辑有重复计数。
第二,空间合理性检查。把合并方案画在地图上,看新站位置是否落在道路两侧都方便到达的地方,有没有落在路口正中间或小区围墙后面。
第三,极端值检查。看单个站点的最大客流是否超过车辆额定载客量乘以班次数,如果超了说明要么客流统计算重了,要么该站确实需要加车而不是合并。
# 总量守恒检查示例 before_total = flow["total"].sum() after_total = sum(simulate_merge(row["flow_a"], row["flow_b"], row["walk_extra"]) for _, row in merge_candidates.iterrows()) print(f"合并前总客流:{before_total:.0f}") print(f"合并后预计总客流:{after_total:.0f}") print(f"变化率:{(after_total - before_total) / before_total * 100:.1f}%")如果变化率超过 -15%,就要回头检查是不是步行时间估得太高或者 beta 取值太激进。这个检查帮我拦下过好几次「看起来很美但实际会赶跑乘客」的方案。
5.3 一个具体技巧:用分位数代替平均值做站点分级
很多教程用平均客流给站点分级,但客流分布通常是长尾的,平均值会被少数大站拉高,导致中小站全被归到「低客流」一类。我习惯用分位数:把站点按总客流排序,前 20% 为一级站,20% 到 50% 为二级站,后 50% 为三级站。这样分级更稳定,也不会因为某个枢纽站客流特别大就把其他站的级别压下去。
flow_total = flow.groupby("stop_id")["total"].sum().reset_index() flow_total["grade"] = pd.qcut(flow_total["total"], q=[0, 0.5, 0.8, 1.0], labels=["三级", "二级", "一级"]) print(flow_total["grade"].value_counts())参数说明:pd.qcut按分位数切分,q=[0, 0.5, 0.8, 1.0]表示后 50% 为三级、50% 到 80% 为二级、前 20% 为一级。这个比例可以根据城市规模调整,大城市枢纽多,一级站比例可以放到 30%。分级结果直接决定了后续资源投入的优先级,比单纯看绝对客流数字更有操作性。
从那以后我每次做站点优化分析,都会先把配置文件和这三个校验步骤跑一遍,再去看具体的合并建议。数据能告诉你哪里有问题,但告不告诉你合并之后乘客愿不愿意走那多出来的两百米,得靠这套校验和实地判断一起兜底。希望帮到你。
本文还有配套的精品资源,点击获取