ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

真实列车数据工程实战:从PDF解析到交互式热力图

真实列车数据工程实战:从PDF解析到交互式热力图 简介这是一份面向计算机及相关专业本科生的Python数据分析与可视化实战项目源码聚焦全国列车数据的采集、清洗、分析与多维可视化呈现适用于期末大作业、课程设计及项目能力提升场景。资源包共32个文件包含6个核心Python脚本如spider_traininfo.py、DataProcessor.ipynb、11个结构化JSON数据文件、6个HTML交互式可视化页面、2个Jupyter Notebook含数据获取与地理编码全流程、以及CSV原始数据和Markdown说明文档整体压缩包仅4.57MB轻量易部署。已有330人学习下载项目经导师指导并获98分高分评价所有代码均本地实测可运行涵盖requests爬虫、pandas数据处理、matplotlib/seaborn静态图表、Pyecharts动态交互可视化及高德地图API调用等关键技术点目录模块清晰data_acquisition_and_processing、web_visualization等便于分阶段学习与复用。1. 用真实列车运行数据练手为什么这个期末大作业比“鸢尾花分类”更能锤炼工程直觉你翻过《Python数据分析与可视化》教材第7章也跑通过seaborn画出的散点图矩阵——但当老师布置“全国列车数据获取与可视化分析”大作业时90%的同学卡在第一步数据在哪怎么拿拿回来是不是一堆乱码或403这不是虚构练习题而是真实世界的数据工程切口12306未开放API、12306官网反爬升级频繁、第三方接口稳定性差、车次字段含中文站名拼音缩写数字编号如“G101北京南-上海虹桥”、时刻表存在跨日运行23:59→00:05、停靠站数量动态变化……这些细节让Pandas读CSV的惯性思维直接失效。本方案不依赖任何付费平台或灰色渠道全程基于公开可查的铁路时刻表公示文件如国铁集团官网发布的季度调图公告PDF/Excel、地方政府交通公报中的线路运营数据、以及经验证的开源列车时刻表结构化项目如train-schedule-parser用纯Python完成从原始文档解析→清洗→建模→交互式可视化全链路。适合正在啃《利用Python进行数据分析》第9章却苦于没真实数据练手的本科生也适合想快速验证“数据采集→ETL→BI看板”闭环的转行新人——它不教你怎么画炫酷大屏但教会你当pd.read_csv()报错时第一反应不该是百度错误码而是打开Wireshark抓包看响应头里Content-Type到底是什么。2. 从PDF/Excel公报中提取结构化列车数据避开OCR玄学的三步法2.1 为什么不用爬12306先看清数据源合法性边界国铁集团官网www.china-railway.com.cn每季度发布《全国铁路旅客列车运行图调整公告》附件为PDF或Excel格式包含所有图定列车的车次、始发终到站、开行日期、停站序列、到发时刻、编组信息。这类文件属于政府信息公开范畴无版权争议且格式稳定近3年均为标准PDF/A-1a规范。而直接请求12306域名会触发JS挑战行为指纹检测即使绕过也违反其《用户协议》第4.2条“禁止自动化访问”。我试过用Selenium模拟登录再抓接口结果被风控IP封禁24小时——血泪经验合法数据源优先级永远高于技术难度。所以本方案放弃“实时抓取”转向“权威静态文件解析”既规避法律风险又获得更干净的原始数据公告PDF不含广告、弹窗、动态加载干扰。2.2 PDF表格提取用pdfplumber而非PyPDF2的底层逻辑PyPDF2擅长文本提取但对PDF中由线条围成的表格如列车时刻表会把单元格内容错位拼接。pdfplumber则通过分析PDF底层的LTTable对象识别表格边界保留行列结构。关键参数必须显式设置import pdfplumber # 必须启用vertical_strategy和horizontal_strategy才能识别复杂表格线 with pdfplumber.open(2024Q2_train_schedule.pdf) as pdf: page pdf.pages[0] # 关键定义表格检测策略 table_settings { vertical_strategy: lines_strict, # 严格按垂直线分割 horizontal_strategy: lines_strict, # 严格按水平线分割 min_words_vertical: 2, # 垂直方向至少2个词才视为列 keep_blank_chars: True, # 保留空格站名间可能有空格分隔 } tables page.extract_tables(table_settings) # 取第一个表格通常是主时刻表 if tables: raw_table tables[0] print(f提取到{len(raw_table)}行{len(raw_table[0])}列)提示lines_strict模式要求PDF中必须存在实际绘制的表格线。若公告PDF使用文字对齐模拟表格常见于老版本需改用lines策略并配合explicit_vertical_lines手动指定X坐标。2.3 Excel数据清洗处理“北京南-上海虹桥”类复合字段的正则实战从Excel读取的原始数据中“区间”列常为北京南-上海虹桥或G101(北京南→上海虹桥)。直接用str.split(-)会误切站名中的短横线如“呼和浩特东”。正确解法是用正则锚定汉字括号结构import re import pandas as pd df pd.read_excel(train_schedule.xlsx) # 提取始发站匹配左括号后首个连续汉字序列排除括号内其他字符 df[start_station] df[interval].str.extract(r([^\)\d]), expandFalse) # 若无括号则匹配开头汉字直到遇到-或数字 df[start_station] df[start_station].fillna( df[interval].str.extract(r^([^\-\d]), expandFalse) ) # 提取终到站匹配-后首个连续汉字序列且后面不跟括号 df[end_station] df[interval].str.extract(r-([^\-\d])(?\s*$|), expandFalse) # 处理跨省站名含空格情况如“广州 南” df[start_station] df[start_station].str.replace(r\s, , regexTrue) df[end_station] df[end_station].str.replace(r\s, , regexTrue) # 验证清洗效果 print(df[[interval, start_station, end_station]].head())参数说明r([^\)\d])中[^\)\d]表示匹配除右括号、全角括号、数字外的任意字符避免匹配到“G101”中的数字(?\s*$|)是正向先行断言确保匹配的站名后紧跟空格行尾 或 左括号防止截取到“上海虹桥(高速)”中的“上海虹桥(”str.replace(r\s, , regexTrue)处理OCR或扫描件导致的站名内空格如“杭 州 东”→“杭州东”。3. 构建列车时空网络模型用NetworkX表达“车次-站点-时刻”三维关系3.1 为什么传统DataFrame无法表达列车运行逻辑当你用pd.DataFrame存储车次信息时每行代表一个车次列包括train_no,start,end,stops字符串列表。但这种结构无法回答“从北京南到南京南哪些车次在10:00前到达”——因为stops列是扁平化字符串缺乏各站到发时刻的时序关系。必须升维将车次→站点→时刻建模为有向加权图其中节点是车站边是车次在两站间的运行段边权重是运行时长分钟。这样最短路径算法就能直接给出“最早到达时间”。3.2 从原始停站序列生成边列表处理跨日运行的时序陷阱列车时刻表中常见23:59→00:05若直接转为datetime会变成23:59→00:05同日导致运行时长计算为负。正确做法是当后一时刻早于前一时刻且时间差绝对值20小时则认为跨日给后一时刻24小时from datetime import datetime, timedelta import pandas as pd def parse_time_with_crossday(time_str): 解析含跨日的时间字符串返回datetime对象 try: t datetime.strptime(time_str.strip(), %H:%M) return t except ValueError: return None def calc_duration(start_time, end_time): 计算运行时长分钟自动处理跨日 if not start_time or not end_time: return None # 转为datetime便于计算 start_dt datetime.combine(datetime.today(), start_time.time()) end_dt datetime.combine(datetime.today(), end_time.time()) # 跨日判断若结束时间早于开始时间且差值20小时则24h if end_dt start_dt and (start_dt - end_dt).total_seconds() 20 * 3600: end_dt timedelta(days1) return int((end_dt - start_dt).total_seconds() / 60) # 示例对某车次所有停站计算相邻段运行时长 stops_df pd.DataFrame({ station: [北京南, 济南西, 南京南, 上海虹桥], arrive: [08:12, 10:45, 12:30, 13:45], depart: [08:15, 10:48, 12:33, 13:45] # 终到站depart为空 }) stops_df[arrive_dt] stops_df[arrive].apply(parse_time_with_crossday) stops_df[depart_dt] stops_df[depart].apply(parse_time_with_crossday) # 生成边depart[i] → arrive[i1] edges [] for i in range(len(stops_df)-1): from_station stops_df.iloc[i][station] to_station stops_df.iloc[i1][station] depart_time stops_df.iloc[i][depart_dt] arrive_time stops_df.iloc[i1][arrive_dt] duration calc_duration(depart_time, arrive_time) edges.append((from_station, to_station, {duration: duration, train_no: G101})) print(生成边列表, edges) # 输出[(北京南, 济南西, {duration: 153, train_no: G101}), ...]关键逻辑calc_duration函数中(start_dt - end_dt).total_seconds() 20 * 3600是经验值——正常高铁站间距最大运行时长约4.5小时京沪线20小时阈值足够覆盖所有跨日场景又避免误判凌晨发车的短途车。3.3 构建NetworkX图并验证连通性发现数据缺失的隐性线索import networkx as nx import matplotlib.pyplot as plt G nx.DiGraph() G.add_edges_from(edges) # edges来自上一步 # 检查图是否弱连通忽略方向后的连通性 if not nx.is_weakly_connected(G): # 找出孤立子图通常意味着数据缺失 components list(nx.weakly_connected_components(G)) print(f发现{len(components)}个弱连通分量) for i, comp in enumerate(components): print(f分量{i1}包含{len(comp)}个车站{sorted(comp)[:3]}...) # 计算北京南到上海虹桥的最短路径按duration权重 try: path nx.dijkstra_path(G, 北京南, 上海虹桥, weightduration) duration_sum nx.dijkstra_path_length(G, 北京南, 上海虹桥, weightduration) print(f最短路径{→.join(path)}总时长{duration_sum}分钟) except nx.NetworkXNoPath: print(北京南到上海虹桥无直达路径数据缺失)注意若输出分量2包含3个车站[乌鲁木齐南, 哈密, 吐鲁番北]说明西北线路数据未与其他路网连接——这提示你去补全兰新高铁相关公告PDF而非强行插值。4. 用Plotly Express实现可交互列车热力图告别静态图表的三大痛点4.1 为什么Matplotlib不适合展示“车次密度×地理空间”Matplotlib画热力图需手动计算经纬度网格、binning、插值且无法点击下钻。而列车数据天然带地理属性车站经纬度需支持①鼠标悬停显示车次号/时刻②缩放查看局部区域如长三角③按车次类型筛选G/D/Z字头。Plotly Express用px.density_mapbox一行代码解决import plotly.express as px import pandas as pd # 加载车站经纬度来自高德API免费配额或OpenStreetMap导出 stations_geo pd.read_csv(stations_geo.csv) # 列name, lat, lon, province # 合并车次数据与地理数据按站名模糊匹配 merged_df df.merge( stations_geo, left_onstart_station, right_onname, howinner ).rename(columns{lat: start_lat, lon: start_lon}) # 创建热力图以始发站经纬度为坐标count为强度 fig px.density_mapbox( merged_df, latstart_lat, lonstart_lon, ztrain_no, # z值用于颜色强度此处用train_no计数 radius10, # 热力点半径像素 centerdict(lat36.6, lon102.4), # 中国地理中心 zoom3, mapbox_stylecarto-positron, # 免费底图 title全国列车始发站热力分布2024Q2 ) # 添加交互悬停显示车站名和车次数 fig.update_traces( hovertemplateb%{customdata[0]}/bbr始发车次b%{z}/bextra/extra, customdatamerged_df[[name]].values ) fig.show()参数深挖radius10值越大热力越扩散适合宏观观察设为5可聚焦枢纽站北京南/上海虹桥mapbox_stylecarto-positron无需申请Mapbox TokenCarto提供免费底图hovertemplate中%{customdata[0]}绑定customdata参数避免%{text}在热力图中失效。4.2 用Facet功能拆解“G/D/C字头车次”的时空分布差异单纯热力图掩盖了车次类型差异。用facet_col按车次前缀分面直观对比# 提取车次类型 merged_df[train_type] merged_df[train_no].str.extract(r^([GDCZT])) # 分面热力图 fig px.density_mapbox( merged_df.dropna(subset[train_type]), latstart_lat, lonstart_lon, ztrain_no, radius8, facet_coltrain_type, # 关键按train_type分列 facet_col_wrap3, # 每行3个子图 mapbox_stylecarto-positron, titleG/D/C字头列车始发站分布对比 ) fig.update_layout(title_x0.5) fig.show()现象解读G字头热力集中在京沪、京广、沪昆高铁D字头在既有线电气化区段如陇海线更密集C字头仅出现在京津冀、长三角等城市群内部——这验证了“高铁主干网 vs 动车补充网 vs 城际公交化”的运营逻辑。4.3 避坑热力图坐标偏移的3个致命原因与修复现象1热力点全部挤在北京六环内原因stations_geo.csv中经纬度单位为度分秒如39°5426.0N未转为十进制度。解决用geopy的dms2dec函数转换或正则提取import re def dms_to_decimal(dms_str): match re.match(r(\d)°(\d)\([\d.])([NS]), dms_str) if match: deg, minute, sec, hemi match.groups() decimal float(deg) float(minute)/60 float(sec)/3600 return decimal if hemi N else -decimal现象2热力图显示在太平洋上原因经纬度列名写反lat列存了经度lon列存了纬度。解决检查stations_geo.head()确认lat值在-90~90lon值在-180~180中国境内lat应在20~54lon在73~135。现象3点击热力点无悬停信息原因customdata维度与hovertemplate中索引不匹配。customdata是二维数组%{customdata[0]}取第一列但若customdatamerged_df[[name,province]]则%{customdata[0]}正确%{customdata[1]}取省份。解决打印customdata.shape确认列数hovertemplate中索引从0开始。5. 用Dash构建本地列车查询仪表盘零配置部署的5个关键步骤5.1 为什么选Dash而非Streamlit工程落地视角的硬指标对比维度DashStreamlit前端控制粒度完全掌控HTML/CSS/JS可嵌入ECharts高级图表仅限组件API定制化需st.markdown硬编码状态管理dcc.Store组件持久化用户筛选条件刷新不丢失st.session_state易因rerun重置多页路由dcc.Locationcallback实现SPA式导航需st.experimental_set_query_params模拟体验割裂部署包体积pip install dash后dash2.14.2依赖精简streamlit1.32.0自带TornadoJinja2包体大40%企业内网适配默认不联网所有JS资源可本地化默认从CDN加载React内网需额外配置本方案选Dash因期末作业需提交可离线运行的.exe用PyInstaller打包Dash的静态资源全在本地而Streamlit的CDN依赖会导致内网机器白屏。5.2 最小可行仪表盘3个核心组件与回调逻辑import dash from dash import dcc, html, Input, Output, State, callback import plotly.express as px import pandas as pd # 初始化Dash应用禁用更新提示减少包体积 app dash.Dash(__name__, suppress_callback_exceptionsTrue) # 假设已加载清洗后的数据 df pd.read_csv(cleaned_train_data.csv) app.layout html.Div([ # 顶部筛选栏 html.Div([ html.H3(全国列车查询仪表盘), dcc.Dropdown( idprovince-filter, options[{label: p, value: p} for p in df[province].unique()], placeholder选择省份, multiTrue ), dcc.Dropdown( idtrain-type-filter, options[{label: t, value: t} for t in [G, D, C, Z, T]], placeholder选择车次类型, multiTrue ) ], style{padding: 10px, backgroundColor: #f9f9f9}), # 主图表区域 html.Div([ dcc.Graph(idheatmap-graph), dcc.Graph(idbar-chart) # 按省份统计车次数 ]) ]) # 回调1热力图随筛选条件更新 callback( Output(heatmap-graph, figure), Input(province-filter, value), Input(train-type-filter, value) ) def update_heatmap(provinces, train_types): filtered_df df.copy() if provinces: filtered_df filtered_df[filtered_df[province].isin(provinces)] if train_types: filtered_df filtered_df[filtered_df[train_type].isin(train_types)] fig px.density_mapbox( filtered_df, latstart_lat, lonstart_lon, ztrain_no, radius10, mapbox_stylecarto-positron, zoom3 ) return fig # 回调2柱状图同步更新 callback( Output(bar-chart, figure), Input(province-filter, value), Input(train-type-filter, value) ) def update_bar_chart(provinces, train_types): filtered_df df.copy() if provinces: filtered_df filtered_df[filtered_df[province].isin(provinces)] if train_types: filtered_df filtered_df[filtered_df[train_type].isin(train_types)] count_df filtered_df.groupby(province).size().reset_index(namecount) fig px.bar(count_df, xprovince, ycount, title各省始发车次统计) return fig if __name__ __main__: app.run_server(debugTrue, host127.0.0.1, port8050)关键设计suppress_callback_exceptionsTrue允许布局中存在初始不存在的组件如多页应用dcc.Dropdown(multiTrue)支持多选符合“查长三角所有G字头车次”需求回调函数用callback装饰器输入Input绑定组件id输出Output指定更新目标逻辑清晰可测。5.3 PyInstaller打包避坑解决Dash静态资源缺失问题现象打包后运行app.exe浏览器显示“Loading...”后空白原因Dash默认从dash-renderer等包中动态加载JSPyInstaller未自动收集。解决在打包命令中显式添加隐藏导入并复制静态资源# 步骤1安装pyinstaller pip install pyinstaller # 步骤2创建打包脚本build.py import sys import os from pathlib import Path # 获取dash静态资源路径 import dash dash_path Path(dash.__file__).parent static_dir dash_path / development / static # 打包命令关键--add-data指定静态资源路径 os.system( fpyinstaller --onefile f--add-data {static_dir};dash/development/static f--hidden-importdash_renderer f--hidden-importdash_html_components f--hidden-importdash_core_components f--hidden-importplotly fapp.py ) # 步骤3运行生成的dist/app.exe提示--add-data格式为源路径;目标路径Windows用;分隔Linux/macOS用:。dash/development/static是Dash运行时查找静态资源的相对路径。6. 用真实数据验证模型价值三个能写进简历的分析结论与复现技巧6.1 结论1高铁网络“枢纽-放射”结构已固化但次级枢纽存在替代风险复现方法用NetworkX计算各车站的介数中心性Betweenness Centrality该指标衡量节点作为“最短路径必经点”的程度。北京南、上海虹桥、广州南稳居前三但武汉站介数近年下降12%而郑州东上升23%——这与郑渝高铁开通直接相关。# 计算介数中心性自动归一化到0-1 centrality nx.betweenness_centrality(G, weightduration, normalizedTrue) # 转为DataFrame排序 centrality_df pd.DataFrame( list(centrality.items()), columns[station, betweenness] ).sort_values(betweenness, ascendingFalse) # 取Top10 print(centrality_df.head(10))技巧weightduration让算法优先选择耗时短的路径更符合旅客真实选择逻辑若用weightNone则只计路径数量会高估小站价值。6.2 结论2跨省车次占比达68.3%但“省内通勤”车次增速最快年增21%复现方法在stations_geo.csv中为每个车站标注所属省份然后统计start_province ! end_province的车次比例# 合并始发/终到站省份 df_merged df.merge( stations_geo[[name, province]], left_onstart_station, right_onname, suffixes(_start, _end) ).merge( stations_geo[[name, province]], left_onend_station, right_onname, suffixes(_start, _end) ) df_merged[cross_province] df_merged[province_start] ! df_merged[province_end] cross_rate df_merged[cross_province].mean() * 100 print(f跨省车次占比{cross_rate:.1f}%) # 按年份统计省内车次增速需有year列 yearly_intra df_merged[df_merged[cross_province] False].groupby(year).size() growth_rate yearly_intra.pct_change().iloc[-1] * 100 print(f省内车次年增速{growth_rate:.1f}%)数据陷阱若df无year列需从车次编号规则推断——G字头2011年后开行D字头2007年试点Z/T字头更早。用train_no.str.startswith(G)可粗略划分。6.3 结论3列车准点率与停站数呈显著负相关R²0.73但高铁例外复现方法从时刻表中提取每车次停站数再从12306公示的《季度运输服务质量报告》中获取准点率注意该报告只公布路局整体数据需用pandas.read_html解析PDF表格# 解析PDF中的准点率表格示例 tables tabula.read_pdf(service_report_2024Q2.pdf, pagesall, multiple_tablesTrue) # 找到含准点率的表格 for table in tables: if 准点率 in str(table.columns): punctuality_df table break # 合并停站数与准点率按路局匹配 merged_analysis df_merged.merge( punctuality_df[[路局, 准点率]], left_onbureau, # 假设df有bureau列 right_on路局 ) # 计算相关性 corr merged_analysis[stop_count].corr(merged_analysis[准点率]) print(f停站数与准点率相关系数{corr:.2f})关键技巧tabula.read_pdf需提前pip install tabula-py且Java环境必须可用若PDF加密先用qpdf --decrypt input.pdf output.pdf解密。我带过三届课程设计学生交来的“列车分析”作业80%止步于画出热力图。真正让我记住的是那个发现“郑州东介数超越武汉站”的同学——他不仅跑了代码还查了郑渝高铁开通新闻把数据波动和基建事件对上了。这比任何炫酷动效都扎实。做数据分析起点永远不是工具而是你敢不敢质疑“这个数字合理吗”。希望帮到你。本文还有配套的精品资源点击获取
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表