
简介这份资源是面向计算机相关专业学生与项目实战学习者的Python数据分析与可视化期末大作业完整源码围绕全国列车数据的获取、清洗与可视化分析展开难度适中适合正在做大作业或需要练手数据分析流程的同学参考。压缩包共32个文件约4.57MB以json数据文件、py爬虫与处理脚本、html与js前端页面、ipynb分析笔记及csv数据集为主另含md说明文档覆盖从数据采集到网页展示的完整链路。项目经导师指导并通过评审源码均本地编译运行、严格调试可直接复现。内容包含列车信息与站点地理数据的爬取脚本、数据清洗与处理笔记、可视化主程序及前端展示页面能帮助读者理解数据获取、结构化处理到图表呈现的完整思路也可作为课程设计模板按需修改。目前已有330人学习下载。1. 全国列车数据获取与可视化一份期末大作业为什么值得认真做12306 的列车时刻数据是很多人做 Python 数据分析与可视化期末大作业时第一个想到、又第一个放弃的题材。想到是因为它天然带地理属性、时间属性和网络结构画出来好看放弃是因为数据获取这一步就卡住了——手动抄几十条车次没意义抓全量又不知道从哪下手。这份「全国列车数据获取与可视化分析」源码要解决的正是从数据获取到可视化图表落地的完整链路用 Python 把车次、站点、时刻、里程这些字段拿下来清洗成规整的表再用可视化把「哪些城市是枢纽」「哪条线路最忙」「发车时间怎么分布」讲清楚。它适合正在做数据分析项目实践的学生也适合想练一遍 requests pandas pyecharts 完整流程的 Python 入门者。下面我按自己复现这类项目的顺序把每一步的参数、坑和取舍讲透。2. 数据从哪来列车数据的获取路径与字段设计2.1 先想清楚要哪些字段再决定怎么拿很多人一上来就写爬虫结果抓回来一堆字段做可视化时发现缺经纬度、缺里程又得回头补。我的习惯是先定表结构再定获取方式。一份能支撑可视化分析的列车数据核心字段大概是这样几类字段类别具体字段用途车次标识车次编号、车型G/D/K/T/Z分类统计、车型占比站点信息始发站、终点站、经停站列表网络图、枢纽识别时间信息发车时间、到达时间、历时时间分布、运行时长分析空间信息站序、里程线路长度、站点密度辅助信息是否当日达、票价区间扩展分析字段定下来之后获取路径其实就两条一是直接请求公开的查询接口拿结构化返回二是拿到页面后自己解析。前者返回的通常是 JSON字段规整省去大量解析工作后者要处理 HTML 结构稳定性差一些。常见做法是优先走接口把返回体存成原始 JSON 落盘再做二次清洗——这样即使后面清洗逻辑改了也不用重新请求省时间也省得给对方服务器添压力。提示请求频率一定要控制。加time.sleep是基本操作间隔设 1 到 2 秒比较稳妥别为了快把并发拉满既容易被限流也不是做作业该有的姿态。2.2 用 requests 拉一页数据的最小可跑代码下面这段是我复现时用的最小骨架先跑通单次请求确认返回结构再考虑批量。import requests import json import time # 请求头里带上 UA很多接口对空 UA 会直接拒绝 HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36, Accept: application/json, text/javascript, */*; q0.01, } def fetch_one(train_no: str, date: str) - dict: train_no: 车次例如 G1 date: 乘车日期格式 2024-06-01 返回接口原始 JSONdict url https://example.com/query # 换成实际查询地址 params {train_no: train_no, date: date} resp requests.get(url, headersHEADERS, paramsparams, timeout10) resp.raise_for_status() # 非 200 直接抛错别让脏数据混进来 return resp.json() if __name__ __main__: data fetch_one(G1, 2024-06-01) # 先落盘原始数据方便反复调试清洗逻辑 with open(raw_G1.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2) print(json.dumps(data, ensure_asciiFalse, indent2)[:800]) time.sleep(1.5)逻辑上就三步拼参数、发请求、落盘。timeout10是防止某个请求卡死拖垮整个脚本raise_for_status()让 4xx/5xx 直接暴露而不是悄悄返回一个空结构让你后面排查半天。ensure_asciiFalse保证中文正常写入不然打开文件全是转义字符。参数里train_no和date是最小组合实际接口可能还要 token 或校验字段按返回的报错逐个补。2.3 批量获取时怎么组织循环和断点续跑单次跑通后批量获取的关键不是并发而是「可恢复」。全国车次几千条跑到一半断了重来最让人崩溃。我的做法是把已抓取的车次记到一个进度文件里每次启动先读进度跳过已完成的。import os import json import time import requests DONE_FILE done_trains.txt def load_done() - set: if not os.path.exists(DONE_FILE): return set() with open(DONE_FILE, r, encodingutf-8) as f: return set(line.strip() for line in f if line.strip()) def mark_done(train_no: str): with open(DONE_FILE, a, encodingutf-8) as f: f.write(train_no \n) def batch_fetch(train_list, date): done load_done() for train_no in train_list: if train_no in done: continue # 断点续跑的核心跳过已完成 try: data fetch_one(train_no, date) with open(fraw_{train_no}.json, w, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse) mark_done(train_no) except Exception as e: print(f{train_no} 失败: {e}) # 单条失败不影响整体 time.sleep(1.5) if __name__ __main__: trains [fG{i} for i in range(1, 200)] # 示例车次列表 batch_fetch(trains, 2024-06-01)这里done_trains.txt就是「后悔药」——脚本崩了、网断了、你手滑 CtrlC 了重跑时它自动跳过抓过的。异常用 try 包住单条一条失败不拖累全局失败的车次不会写进进度文件下次自动重试。sleep(1.5)放在循环末尾保证每条之间都有间隔。这套结构不花哨但跑几千条数据时能省下大量重跑时间。3. 把原始 JSON 洗成能画图的表pandas 清洗与字段派生3.1 从嵌套 JSON 到扁平 DataFrame接口返回的 JSON 往往是嵌套的一个车次下面挂一个经停站数组每个站又是一个对象。直接塞进 DataFrame 会得到一列全是 dict 的尴尬结构。正确做法是先按「一个车次一行」和「一个经停站一行」拆成两张表再按需关联。import json import glob import pandas as pd def parse_train_file(path: str): with open(path, r, encodingutf-8) as f: raw json.load(f) # 车次级别的信息 train_row { train_no: raw.get(train_no), train_type: raw.get(train_no, )[0], # 取首字母做车型 from_station: raw.get(from_station), to_station: raw.get(to_station), start_time: raw.get(start_time), arrive_time: raw.get(arrive_time), duration: raw.get(lishi), # 历时字符串如 04:28 } # 经停站级别一行一个站 stop_rows [] for idx, stop in enumerate(raw.get(stops, []), start1): stop_rows.append({ train_no: raw.get(train_no), station_seq: idx, station_name: stop.get(station_name), arrive_time: stop.get(arrive_time), start_time: stop.get(start_time), mileage: stop.get(mileage), # 累计里程单位 km }) return train_row, stop_rows def build_tables(patternraw_*.json): trains, stops [], [] for path in glob.glob(pattern): t, s parse_train_file(path) trains.append(t) stops.extend(s) df_train pd.DataFrame(trains) df_stop pd.DataFrame(stops) return df_train, df_stop if __name__ __main__: df_train, df_stop build_tables() print(df_train.shape, df_stop.shape) df_train.to_csv(train_info.csv, indexFalse, encodingutf-8-sig) df_stop.to_csv(train_stops.csv, indexFalse, encodingutf-8-sig)拆两张表的好处是车次表用来做车型占比、发车时段统计经停站表用来做站点热度、线路网络。encodingutf-8-sig是为了 Excel 打开不乱码做作业交上去老师大概率用 Excel 看这个细节能省掉一次返工。train_type直接取车次首字母G/D/K/T/Z 一目了然比后面再写映射简单。3.2 时间字段和里程字段的派生原始数据里的时间多是字符串直接画图排序会乱。里程字段有时是累计值有时缺省需要补。下面这段处理时间差和站点间距离。import pandas as pd def to_minutes(t: str): 把 HH:MM 转成分钟数跨天的情况简单加 24h 处理 if not t or : not in str(t): return None h, m str(t).split(:)[:2] return int(h) * 60 int(m) def enrich(df_train: pd.DataFrame, df_stop: pd.DataFrame): # 历时转分钟 df_train[duration_min] df_train[duration].apply(to_minutes) # 发车小时用于时段分布 df_train[start_hour] df_train[start_time].apply( lambda x: int(str(x).split(:)[0]) if x and : in str(x) else None ) # 站点间距离 当前站累计里程 - 上一站累计里程 df_stop[mileage] pd.to_numeric(df_stop[mileage], errorscoerce) df_stop df_stop.sort_values([train_no, station_seq]) df_stop[seg_mileage] df_stop.groupby(train_no)[mileage].diff() return df_train, df_stop if __name__ __main__: df_train pd.read_csv(train_info.csv) df_stop pd.read_csv(train_stops.csv) df_train, df_stop enrich(df_train, df_stop) print(df_train[[train_no, duration_min, start_hour]].head()) print(df_stop[[train_no, station_name, seg_mileage]].head())to_minutes把时间统一成分钟后面算平均历时、画时长分布都方便。groupby(train_no)[mileage].diff()是算相邻站里程差的关键第一站 diff 出来是 NaN属于正常画图时 dropna 即可。errorscoerce把无法转数字的值变成 NaN避免整列报错这是清洗脏数据时的保命参数。3.3 清洗阶段最容易忽略的三件事第一是去重。同一车次可能因为分页或重试被抓了两次落盘成两个文件合并后出现重复行。合并完先df.drop_duplicates(subset[train_no])一下成本极低。第二是空值统计df.isnull().sum()跑一遍看看哪个字段缺失严重缺得多的字段就别硬拿去做核心图表。第三是类型统一里程、序号这类字段从 JSON 读出来可能是字符串pd.to_numeric统一转一遍不然后面排序会出现「100 排在 20 前面」的字符串排序玄学。这三件事做完数据才算真正能进可视化环节。4. 可视化怎么选图从枢纽识别到时段分布的落地4.1 站点热度用条形图还是网络图站点出现频次是最容易做的指标把经停站表按station_name分组计数取 Top 20 画横向条形图一眼看出哪些站是枢纽。但条形图只能表达「次数」表达不了「连接关系」。如果想展示「哪些城市之间互通」就得用网络图节点是站、边是同一车次相邻两站。import pandas as pd from pyecharts import options as opts from pyecharts.charts import Bar def top_stations(df_stop: pd.DataFrame, top_n20): cnt (df_stop.groupby(station_name) .size() .reset_index(namecount) .sort_values(count, ascendingFalse) .head(top_n)) bar ( Bar() .add_xaxis(cnt[station_name].tolist()[::-1]) .add_yaxis(经停次数, cnt[count].tolist()[::-1]) .reversal_axis() .set_global_opts( title_optsopts.TitleOpts(title全国列车经停次数 Top20 站点), xaxis_optsopts.AxisOpts(name次数), yaxis_optsopts.AxisOpts(name站点), ) ) bar.render(top_stations.html) if __name__ __main__: df_stop pd.read_csv(train_stops.csv) top_stations(df_stop)reversal_axis()把条形图转成横向站点名长的时候不会挤在一起。[::-1]反转顺序是因为横向条形图默认从下往上画不反转的话最大值会跑到最下面看着别扭。render输出 HTML交作业时直接打开就能看比截图清晰。如果要做网络图pyecharts 的 Graph 组件可以接节点和边数据但节点一多浏览器会卡建议先按频次筛出前 100 个站再做。4.2 发车时段分布和车型占比发车时段用直方图或柱状图都行把start_hour按 0 到 23 分组计数。车型占比用饼图train_type分组。这两个图放一起能讲出「高铁集中在白天、普速覆盖夜间」这类结论比单纯堆图有说服力。from pyecharts.charts import Pie, Bar from pyecharts import options as opts def hour_distribution(df_train: pd.DataFrame): df df_train.dropna(subset[start_hour]) cnt df.groupby(start_hour).size().reindex(range(24), fill_value0) bar ( Bar() .add_xaxis([f{h}时 for h in cnt.index]) .add_yaxis(发车数量, cnt.tolist()) .set_global_opts( title_optsopts.TitleOpts(title全国列车发车时段分布), xaxis_optsopts.AxisOpts(name小时), yaxis_optsopts.AxisOpts(name车次数), ) ) bar.render(hour_dist.html) def type_pie(df_train: pd.DataFrame): cnt df_train.groupby(train_type).size() pie ( Pie() .add(, [list(z) for z in zip(cnt.index.tolist(), cnt.tolist())]) .set_global_opts(title_optsopts.TitleOpts(title车型占比)) .set_series_opts(label_optsopts.LabelOpts(formatter{b}: {d}%)) ) pie.render(type_pie.html) if __name__ __main__: df_train pd.read_csv(train_info.csv) hour_distribution(df_train) type_pie(df_train)reindex(range(24), fill_value0)是关键保证 0 到 23 点每个小时都有位置哪怕某小时没车次也补 0不然横坐标会跳号。饼图的{d}%是 pyecharts 的百分比占位符自动算比例不用自己除。这两个图数据量小渲染快适合放在报告开头做概览。4.3 用地图把线路画出来需要补什么数据想画线路地图光有站名不够得有经纬度。站点经纬度不在列车数据里需要额外准备一份「站名 → 经纬度」的对照表。常见做法是找一份公开的城市经纬度表按站名里的城市名做匹配匹配不上的手动补。这一步是整个可视化里最费时的但地图出来的效果也最直观。import pandas as pd from pyecharts.charts import Geo from pyecharts import options as opts def draw_geo(df_stop: pd.DataFrame, coord_filestation_coord.csv): coord pd.read_csv(coord_file) # 列station_name, lng, lat merged df_stop.merge(coord, onstation_name, howinner) # 按站点聚合取出现次数作为热度 heat merged.groupby([station_name, lng, lat]).size().reset_index(namecount) geo ( Geo() .add_schema(maptypechina) .add( 站点热度, [list(z) for z in zip(heat[station_name], heat[count])], type_effectScatter, ) .set_global_opts( title_optsopts.TitleOpts(title全国列车站点热度分布), visualmap_optsopts.VisualMapOpts(max_int(heat[count].max())), ) ) geo.render(station_geo.html) if __name__ __main__: df_stop pd.read_csv(train_stops.csv) draw_geo(df_stop)merge(..., howinner)只保留能匹配上经纬度的站匹配不上的直接丢避免地图上出现空点。effectScatter是带涟漪效果的散点适合表达热度。VisualMapOpts的max_设成最大频次颜色深浅才有区分度。如果站点太多地图糊成一团可以先按频次筛前 50 个再画。5. 避坑与排查这类项目最容易翻车的五个地方5.1 请求返回空数据或结构突变现象脚本跑着跑着某些车次返回的 JSON 里stops是空的或者字段名和之前不一样。原因通常是接口对高频请求做了限制或者不同车型返回结构本身有差异。解决在解析前先判断关键字段是否存在raw.get(stops, [])给个默认空列表别让KeyError直接中断同时对返回为空的记录单独记日志跑完统一看是哪些车次、是不是集中在某个时段判断是限流还是数据本身如此。5.2 中文乱码现象CSV 用 Excel 打开全是乱码。原因pandas 默认utf-8写入Excel 在中文环境下按 GBK 解读。解决写 CSV 时统一用encodingutf-8-sig这个 BOM 头能让 Excel 正确识别。如果已经写坏了用pd.read_csv(..., encodingutf-8)读回来再以utf-8-sig重写一遍即可。5.3 时间跨天导致历时算成负数现象夜间车次到达时间小于发车时间直接相减得到负数。原因跨天车次的到达时间其实是第二天。解决在to_minutes里判断如果到达分钟数小于发车分钟数给到达时间加 1440 分钟。这个判断要放在算历时之前不然所有夜间车次的时长都是错的。5.4 地图上站点位置全挤在东部现象Geo 图出来点全堆在几个大城市西部几乎空白。原因一是数据本身东部车次就多二是部分站名匹配不到经纬度被 inner join 丢了。解决先统计匹配率len(merged) / len(df_stop)低于 80% 就说明对照表覆盖不够需要补站名映射如果匹配率够但分布仍集中那是数据真实分布可以在报告里说明而不是硬调。5.5 pyecharts 图表打开是空白现象HTML 文件打开一片白。原因多半是数据里混了 NaN 或 Nonepyecharts 序列化时出错但不报明显错误。解决画图前对要用的列统一dropna()数值列pd.to_numeric(..., errorscoerce)后再 dropna。养成「进图前先 dropna」的习惯能省掉大量对着空白页面发呆的时间。6. 让作业从「能跑」到「能讲」一个提分技巧做到上面几步图能出来、数据能看作业已经及格了。但期末大作业拉开差距的地方往往不是图多而是「你有没有从数据里讲出一个别人没注意的结论」。我自己的习惯是所有图做完后回头找一张最普通的图往下再挖一层。比如发车时段分布图大部分人画完就结束了。但如果你把车型拆开分别看 G 字头和 K 字头的发车时段会发现一个很明显的错位高铁集中在 6 点到 22 点普速则大量分布在夜间和凌晨。这个结论本身不新鲜但如果你再叠一层——统计每个时段里「当日达」车次的比例就能讲出「夜间发车的车次里有多少是能当天到的、有多少要跨天」这就从描述变成了分析。具体做法是在清洗阶段多派生一个字段import pandas as pd def add_same_day_flag(df_train: pd.DataFrame) - pd.DataFrame: 判断是否当日达发车分钟 历时分钟 1440 df_train[start_min] df_train[start_time].apply( lambda x: int(str(x).split(:)[0]) * 60 int(str(x).split(:)[1]) if x and : in str(x) else None ) df_train[end_min] df_train[start_min] df_train[duration_min] df_train[same_day] df_train[end_min] 1440 return df_train if __name__ __main__: df_train pd.read_csv(train_info.csv) df_train add_same_day_flag(df_train) # 按车型看当日达比例 ratio df_train.groupby(train_type)[same_day].mean().sort_values(ascendingFalse) print(ratio)same_day这个布尔字段一加能做的分析立刻多了一层按车型看当日达比例、按发车时段看当日达比例、按线路长度看当日达比例。groupby(...).mean()对布尔列求均值直接得到 True 的占比比sum()/count()简洁。这类派生字段成本很低但能让你的报告从「我画了图」变成「我从图里发现了什么」。另一个提分点是图表命名和注释。title_opts里别只写「发车分布」写成「全国列车发车时段分布按小时含夜间车次」把口径写清楚。老师看报告时图注比图本身更能体现你有没有想清楚。我吃过这个亏——早期交作业图很花哨但每张图标题都是「图1」「图2」被批「不知道你想表达什么」。后来养成习惯每张图的标题就是一句结论比如「高铁发车集中在 6-22 点普速覆盖全天」图只是佐证。最后说个数据量上的经验全国车次全量抓下来原始 JSON 加上清洗后的 CSV体积不小跑清洗和画图时内存会吃紧。我的做法是清洗阶段只保留分析要用的列df df[[train_no, station_name, mileage]]这种把宽表裁成窄表后面所有操作都轻快很多。这个习惯在做任何数据分析项目时都适用——数据不是越多越好够用且干净才是。希望帮到你。本文还有配套的精品资源点击获取