據(jù)獲取與可視化分析:Python爬蟲+pandas+pyecharts完整實戰(zhàn))
簡介這份資源是面向計算機相關(guān)專業(yè)學(xué)生與項目實戰(zhàn)學(xué)習(xí)者的Python數(shù)據(jù)分析與可視化期末大作業(yè)完整源碼圍繞全國列車數(shù)據(jù)的獲取、清洗與可視化分析展開難度適中適合正在做大作業(yè)或需要練手數(shù)據(jù)分析流程的同學(xué)參考。壓縮包共32個文件約4.57MB以json數(shù)據(jù)文件、py爬蟲與處理腳本、html與js前端頁面、ipynb分析筆記及csv數(shù)據(jù)集為主另含md說明文檔覆蓋從數(shù)據(jù)采集到網(wǎng)頁展示的完整鏈路。項目經(jīng)導(dǎo)師指導(dǎo)并通過評審源碼均本地編譯運行、嚴(yán)格調(diào)試可直接復(fù)現(xiàn)。內(nèi)容包含列車信息與站點地理數(shù)據(jù)的爬取腳本、數(shù)據(jù)清洗與處理筆記、可視化主程序及前端展示頁面能幫助讀者理解數(shù)據(jù)獲取、結(jié)構(gòu)化處理到圖表呈現(xiàn)的完整思路也可作為課程設(shè)計模板按需修改。目前已有330人學(xué)習(xí)下載。1. 全國列車數(shù)據(jù)獲取與可視化一份期末大作業(yè)為什么值得認真做12306 的列車時刻數(shù)據(jù)是很多人做 Python 數(shù)據(jù)分析與可視化期末大作業(yè)時第一個想到、又第一個放棄的題材。想到是因為它天然帶地理屬性、時間屬性和網(wǎng)絡(luò)結(jié)構(gòu)畫出來好看放棄是因為數(shù)據(jù)獲取這一步就卡住了——手動抄幾十條車次沒意義抓全量又不知道從哪下手。這份「全國列車數(shù)據(jù)獲取與可視化分析」源碼要解決的正是從數(shù)據(jù)獲取到可視化圖表落地的完整鏈路用 Python 把車次、站點、時刻、里程這些字段拿下來清洗成規(guī)整的表再用可視化把「哪些城市是樞紐」「哪條線路最忙」「發(fā)車時間怎么分布」講清楚。它適合正在做數(shù)據(jù)分析項目實踐的學(xué)生也適合想練一遍 requests pandas pyecharts 完整流程的 Python 入門者。下面我按自己復(fù)現(xiàn)這類項目的順序把每一步的參數(shù)、坑和取舍講透。2. 數(shù)據(jù)從哪來列車數(shù)據(jù)的獲取路徑與字段設(shè)計2.1 先想清楚要哪些字段再決定怎么拿很多人一上來就寫爬蟲結(jié)果抓回來一堆字段做可視化時發(fā)現(xiàn)缺經(jīng)緯度、缺里程又得回頭補。我的習(xí)慣是先定表結(jié)構(gòu)再定獲取方式。一份能支撐可視化分析的列車數(shù)據(jù)核心字段大概是這樣幾類字段類別具體字段用途車次標(biāo)識車次編號、車型G/D/K/T/Z分類統(tǒng)計、車型占比站點信息始發(fā)站、終點站、經(jīng)停站列表網(wǎng)絡(luò)圖、樞紐識別時間信息發(fā)車時間、到達時間、歷時時間分布、運行時長分析空間信息站序、里程線路長度、站點密度輔助信息是否當(dāng)日達、票價區(qū)間擴展分析字段定下來之后獲取路徑其實就兩條一是直接請求公開的查詢接口拿結(jié)構(gòu)化返回二是拿到頁面后自己解析。前者返回的通常是 JSON字段規(guī)整省去大量解析工作后者要處理 HTML 結(jié)構(gòu)穩(wěn)定性差一些。常見做法是優(yōu)先走接口把返回體存成原始 JSON 落盤再做二次清洗——這樣即使后面清洗邏輯改了也不用重新請求省時間也省得給對方服務(wù)器添壓力。提示請求頻率一定要控制。加time.sleep是基本操作間隔設(shè) 1 到 2 秒比較穩(wěn)妥別為了快把并發(fā)拉滿既容易被限流也不是做作業(yè)該有的姿態(tài)。2.2 用 requests 拉一頁數(shù)據(jù)的最小可跑代碼下面這段是我復(fù)現(xiàn)時用的最小骨架先跑通單次請求確認返回結(jié)構(gòu)再考慮批量。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 直接拋錯別讓臟數(shù)據(jù)混進來 return resp.json() if __name__ __main__: data fetch_one(G1, 2024-06-01) # 先落盤原始數(shù)據(jù)方便反復(fù)調(diào)試清洗邏輯 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)邏輯上就三步拼參數(shù)、發(fā)請求、落盤。timeout10是防止某個請求卡死拖垮整個腳本raise_for_status()讓 4xx/5xx 直接暴露而不是悄悄返回一個空結(jié)構(gòu)讓你后面排查半天。ensure_asciiFalse保證中文正常寫入不然打開文件全是轉(zhuǎn)義字符。參數(shù)里train_no和date是最小組合實際接口可能還要 token 或校驗字段按返回的報錯逐個補。2.3 批量獲取時怎么組織循環(huán)和斷點續(xù)跑單次跑通后批量獲取的關(guān)鍵不是并發(fā)而是「可恢復(fù)」。全國車次幾千條跑到一半斷了重來最讓人崩潰。我的做法是把已抓取的車次記到一個進度文件里每次啟動先讀進度跳過已完成的。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 # 斷點續(xù)跑的核心跳過已完成 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就是「后悔藥」——腳本崩了、網(wǎng)斷了、你手滑 CtrlC 了重跑時它自動跳過抓過的。異常用 try 包住單條一條失敗不拖累全局失敗的車次不會寫進進度文件下次自動重試。sleep(1.5)放在循環(huán)末尾保證每條之間都有間隔。這套結(jié)構(gòu)不花哨但跑幾千條數(shù)據(jù)時能省下大量重跑時間。3. 把原始 JSON 洗成能畫圖的表pandas 清洗與字段派生3.1 從嵌套 JSON 到扁平 DataFrame接口返回的 JSON 往往是嵌套的一個車次下面掛一個經(jīng)停站數(shù)組每個站又是一個對象。直接塞進 DataFrame 會得到一列全是 dict 的尷尬結(jié)構(gòu)。正確做法是先按「一個車次一行」和「一個經(jīng)停站一行」拆成兩張表再按需關(guān)聯(lián)。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 } # 經(jīng)停站級別一行一個站 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)拆兩張表的好處是車次表用來做車型占比、發(fā)車時段統(tǒng)計經(jīng)停站表用來做站點熱度、線路網(wǎng)絡(luò)。encodingutf-8-sig是為了 Excel 打開不亂碼做作業(yè)交上去老師大概率用 Excel 看這個細節(jié)能省掉一次返工。train_type直接取車次首字母G/D/K/T/Z 一目了然比后面再寫映射簡單。3.2 時間字段和里程字段的派生原始數(shù)據(jù)里的時間多是字符串直接畫圖排序會亂。里程字段有時是累計值有時缺省需要補。下面這段處理時間差和站點間距離。import pandas as pd def to_minutes(t: str): 把 HH:MM 轉(zhuǎn)成分鐘數(shù)跨天的情況簡單加 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): # 歷時轉(zhuǎn)分鐘 df_train[duration_min] df_train[duration].apply(to_minutes) # 發(fā)車小時用于時段分布 df_train[start_hour] df_train[start_time].apply( lambda x: int(str(x).split(:)[0]) if x and : in str(x) else None ) # 站點間距離 當(dāng)前站累計里程 - 上一站累計里程 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把時間統(tǒng)一成分鐘后面算平均歷時、畫時長分布都方便。groupby(train_no)[mileage].diff()是算相鄰站里程差的關(guān)鍵第一站 diff 出來是 NaN屬于正常畫圖時 dropna 即可。errorscoerce把無法轉(zhuǎn)數(shù)字的值變成 NaN避免整列報錯這是清洗臟數(shù)據(jù)時的保命參數(shù)。3.3 清洗階段最容易忽略的三件事第一是去重。同一車次可能因為分頁或重試被抓了兩次落盤成兩個文件合并后出現(xiàn)重復(fù)行。合并完先df.drop_duplicates(subset[train_no])一下成本極低。第二是空值統(tǒng)計df.isnull().sum()跑一遍看看哪個字段缺失嚴(yán)重缺得多的字段就別硬拿去做核心圖表。第三是類型統(tǒng)一里程、序號這類字段從 JSON 讀出來可能是字符串pd.to_numeric統(tǒng)一轉(zhuǎn)一遍不然后面排序會出現(xiàn)「100 排在 20 前面」的字符串排序玄學(xué)。這三件事做完數(shù)據(jù)才算真正能進可視化環(huán)節(jié)。4. 可視化怎么選圖從樞紐識別到時段分布的落地4.1 站點熱度用條形圖還是網(wǎng)絡(luò)圖站點出現(xiàn)頻次是最容易做的指標(biāo)把經(jīng)停站表按station_name分組計數(shù)取 Top 20 畫橫向條形圖一眼看出哪些站是樞紐。但條形圖只能表達「次數(shù)」表達不了「連接關(guān)系」。如果想展示「哪些城市之間互通」就得用網(wǎng)絡(luò)圖節(jié)點是站、邊是同一車次相鄰兩站。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(經(jīng)停次數(shù), cnt[count].tolist()[::-1]) .reversal_axis() .set_global_opts( title_optsopts.TitleOpts(title全國列車經(jīng)停次數(shù) Top20 站點), xaxis_optsopts.AxisOpts(name次數(shù)), 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()把條形圖轉(zhuǎn)成橫向站點名長的時候不會擠在一起。[::-1]反轉(zhuǎn)順序是因為橫向條形圖默認從下往上畫不反轉(zhuǎn)的話最大值會跑到最下面看著別扭。render輸出 HTML交作業(yè)時直接打開就能看比截圖清晰。如果要做網(wǎng)絡(luò)圖pyecharts 的 Graph 組件可以接節(jié)點和邊數(shù)據(jù)但節(jié)點一多瀏覽器會卡建議先按頻次篩出前 100 個站再做。4.2 發(fā)車時段分布和車型占比發(fā)車時段用直方圖或柱狀圖都行把start_hour按 0 到 23 分組計數(shù)。車型占比用餅圖train_type分組。這兩個圖放一起能講出「高鐵集中在白天、普速覆蓋夜間」這類結(jié)論比單純堆圖有說服力。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(發(fā)車數(shù)量, cnt.tolist()) .set_global_opts( title_optsopts.TitleOpts(title全國列車發(fā)車時段分布), xaxis_optsopts.AxisOpts(name小時), yaxis_optsopts.AxisOpts(name車次數(shù)), ) ) 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: zxl6ubaz%)) ) 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)是關(guān)鍵保證 0 到 23 點每個小時都有位置哪怕某小時沒車次也補 0不然橫坐標(biāo)會跳號。餅圖的zxl6ubaz%是 pyecharts 的百分比占位符自動算比例不用自己除。這兩個圖數(shù)據(jù)量小渲染快適合放在報告開頭做概覽。4.3 用地圖把線路畫出來需要補什么數(shù)據(jù)想畫線路地圖光有站名不夠得有經(jīng)緯度。站點經(jīng)緯度不在列車數(shù)據(jù)里需要額外準(zhǔn)備一份「站名 → 經(jīng)緯度」的對照表。常見做法是找一份公開的城市經(jīng)緯度表按站名里的城市名做匹配匹配不上的手動補。這一步是整個可視化里最費時的但地圖出來的效果也最直觀。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) # 按站點聚合取出現(xiàn)次數(shù)作為熱度 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)只保留能匹配上經(jīng)緯度的站匹配不上的直接丟避免地圖上出現(xiàn)空點。effectScatter是帶漣漪效果的散點適合表達熱度。VisualMapOpts的max_設(shè)成最大頻次顏色深淺才有區(qū)分度。如果站點太多地圖糊成一團可以先按頻次篩前 50 個再畫。5. 避坑與排查這類項目最容易翻車的五個地方5.1 請求返回空數(shù)據(jù)或結(jié)構(gòu)突變現(xiàn)象腳本跑著跑著某些車次返回的 JSON 里stops是空的或者字段名和之前不一樣。原因通常是接口對高頻請求做了限制或者不同車型返回結(jié)構(gòu)本身有差異。解決在解析前先判斷關(guān)鍵字段是否存在raw.get(stops, [])給個默認空列表別讓KeyError直接中斷同時對返回為空的記錄單獨記日志跑完統(tǒng)一看是哪些車次、是不是集中在某個時段判斷是限流還是數(shù)據(jù)本身如此。5.2 中文亂碼現(xiàn)象CSV 用 Excel 打開全是亂碼。原因pandas 默認utf-8寫入Excel 在中文環(huán)境下按 GBK 解讀。解決寫 CSV 時統(tǒng)一用encodingutf-8-sig這個 BOM 頭能讓 Excel 正確識別。如果已經(jīng)寫壞了用pd.read_csv(..., encodingutf-8)讀回來再以utf-8-sig重寫一遍即可。5.3 時間跨天導(dǎo)致歷時算成負數(shù)現(xiàn)象夜間車次到達時間小于發(fā)車時間直接相減得到負數(shù)。原因跨天車次的到達時間其實是第二天。解決在to_minutes里判斷如果到達分鐘數(shù)小于發(fā)車分鐘數(shù)給到達時間加 1440 分鐘。這個判斷要放在算歷時之前不然所有夜間車次的時長都是錯的。5.4 地圖上站點位置全擠在東部現(xiàn)象Geo 圖出來點全堆在幾個大城市西部幾乎空白。原因一是數(shù)據(jù)本身東部車次就多二是部分站名匹配不到經(jīng)緯度被 inner join 丟了。解決先統(tǒng)計匹配率len(merged) / len(df_stop)低于 80% 就說明對照表覆蓋不夠需要補站名映射如果匹配率夠但分布仍集中那是數(shù)據(jù)真實分布可以在報告里說明而不是硬調(diào)。5.5 pyecharts 圖表打開是空白現(xiàn)象HTML 文件打開一片白。原因多半是數(shù)據(jù)里混了 NaN 或 Nonepyecharts 序列化時出錯但不報明顯錯誤。解決畫圖前對要用的列統(tǒng)一dropna()數(shù)值列pd.to_numeric(..., errorscoerce)后再 dropna。養(yǎng)成「進圖前先 dropna」的習(xí)慣能省掉大量對著空白頁面發(fā)呆的時間。6. 讓作業(yè)從「能跑」到「能講」一個提分技巧做到上面幾步圖能出來、數(shù)據(jù)能看作業(yè)已經(jīng)及格了。但期末大作業(yè)拉開差距的地方往往不是圖多而是「你有沒有從數(shù)據(jù)里講出一個別人沒注意的結(jié)論」。我自己的習(xí)慣是所有圖做完后回頭找一張最普通的圖往下再挖一層。比如發(fā)車時段分布圖大部分人畫完就結(jié)束了。但如果你把車型拆開分別看 G 字頭和 K 字頭的發(fā)車時段會發(fā)現(xiàn)一個很明顯的錯位高鐵集中在 6 點到 22 點普速則大量分布在夜間和凌晨。這個結(jié)論本身不新鮮但如果你再疊一層——統(tǒng)計每個時段里「當(dāng)日達」車次的比例就能講出「夜間發(fā)車的車次里有多少是能當(dāng)天到的、有多少要跨天」這就從描述變成了分析。具體做法是在清洗階段多派生一個字段import pandas as pd def add_same_day_flag(df_train: pd.DataFrame) - pd.DataFrame: 判斷是否當(dāng)日達發(fā)車分鐘 歷時分鐘 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) # 按車型看當(dāng)日達比例 ratio df_train.groupby(train_type)[same_day].mean().sort_values(ascendingFalse) print(ratio)same_day這個布爾字段一加能做的分析立刻多了一層按車型看當(dāng)日達比例、按發(fā)車時段看當(dāng)日達比例、按線路長度看當(dāng)日達比例。groupby(...).mean()對布爾列求均值直接得到 True 的占比比sum()/count()簡潔。這類派生字段成本很低但能讓你的報告從「我畫了圖」變成「我從圖里發(fā)現(xiàn)了什么」。另一個提分點是圖表命名和注釋。title_opts里別只寫「發(fā)車分布」寫成「全國列車發(fā)車時段分布按小時含夜間車次」把口徑寫清楚。老師看報告時圖注比圖本身更能體現(xiàn)你有沒有想清楚。我吃過這個虧——早期交作業(yè)圖很花哨但每張圖標(biāo)題都是「圖1」「圖2」被批「不知道你想表達什么」。后來養(yǎng)成習(xí)慣每張圖的標(biāo)題就是一句結(jié)論比如「高鐵發(fā)車集中在 6-22 點普速覆蓋全天」圖只是佐證。最后說個數(shù)據(jù)量上的經(jīng)驗全國車次全量抓下來原始 JSON 加上清洗后的 CSV體積不小跑清洗和畫圖時內(nèi)存會吃緊。我的做法是清洗階段只保留分析要用的列df df[[train_no, station_name, mileage]]這種把寬表裁成窄表后面所有操作都輕快很多。這個習(xí)慣在做任何數(shù)據(jù)分析項目時都適用——數(shù)據(jù)不是越多越好夠用且干凈才是。希望幫到你。本文還有配套的精品資源點擊獲取