寫在前面:本文從北京公交路線數(shù)據(jù)的獲取和預(yù)處理入手,記錄使用python中requests庫獲取數(shù)據(jù),pandas庫預(yù)處理數(shù)據(jù)的過程。文章在保證按照一定處理邏輯的前提下,以自問自答的方式,對其中每一個環(huán)節(jié)進(jìn)行詳細(xì)闡述。本次代碼均在jupyter notebook中測試通過,希望對大家有所啟示。
數(shù)據(jù)獲取:
本次我們從公交網(wǎng)獲取北京公交的數(shù)據(jù)。
(http://beijing./lines_all.html)

如上圖所示,數(shù)據(jù)獲取分為請求,解析,存儲三個最主要的步驟。
1.如何用python模擬網(wǎng)絡(luò)請求?
使用request庫可以模擬不同的請求,例如requests.get()
模擬get請求,requests.post()
模擬post請求。必要的時候可以添加請求頭header,header通常包括user-agent,cookie,refer等信息,還可以增加請求參數(shù)data和代理信息。主要代碼形式為:response = requests.request('GET', url, headers=headers, params=querystring)
response是網(wǎng)站返回的響應(yīng)信息,可以調(diào)用其text方法獲取網(wǎng)站的HTML源碼。本次我們的目標(biāo)網(wǎng)站比較簡單,獲取網(wǎng)頁源碼的代碼如下:
1url = 'http://beijing./lines_all.html'
2text = requests.get(url).text
2.如何對網(wǎng)頁進(jìn)行解析?
python中提供了多種庫用于網(wǎng)頁解析,例如lxml,BeautifulSoup,pyquery等。每一個工具都有相應(yīng)的解析規(guī)則,但都是把HTML文檔當(dāng)做一個DOM樹,通過選擇器進(jìn)行節(jié)點和屬性的定位。本次我們使用lxml對網(wǎng)頁進(jìn)行解析,主要用到了xpath的語法。lxml的執(zhí)行效率通常也比BeautifulSoup更高一些。
1doc = etree.HTML(text)
2all_lines = doc.xpath('//div[@class='list']/ul/li')
3for line in all_lines:
4 line_name = line.xpath('./a/text()')[0].strip()
5 line_url = line.xpath('./a/@href')[0]

我們將圖和代碼結(jié)合起來看。第一行代碼將上一步返回的HTML文本轉(zhuǎn)換為xpath可以解析的對象。第二行代碼定位到class=list
的div下面所有的li
標(biāo)簽,即右圖中的紅色框的部分,得到的是一個列表。從第三行開始對其進(jìn)行遍歷,處理每一個li
下面的a
標(biāo)簽。第4行取出a
標(biāo)簽下的文本,用到了xpath的text()
方法,對應(yīng)到第一個li
就是“北京1路公交車路線”,第5行取出a
標(biāo)簽下對應(yīng)的鏈接,用到了xpath的@href
取出a
標(biāo)簽下的href
屬性值。直接取都是列表的形式,所以需要用索引取出具體的值。
這樣我們就可以得到整個公交線路列表中的線路名稱和線路url。然后從線路url出發(fā),就可以獲取每條線路的具體信息。如下面代碼和圖片所示,雖然數(shù)據(jù)略多,但主要的邏輯和上面類似,可以查看代碼中的注釋。

注:左右滑動查看詳細(xì)代碼
1url = 'http://beijing./xianlu_38753'#先以一個url為例,進(jìn)行頁面的分析
2text = requests.get(url).text
3print(len(text))
4doc = etree.HTML(text)
5infos = doc.xpath('//div[@class='gj01_line_header clearfix']')#定位到相應(yīng)的div塊
6for info in infos:
7 start_stop = info.xpath('./dl/dt/a/text()')#獲取起點站和終點站的文本,xpath的邏輯為:div->dl->dt->a
8 op_times = info.xpath('./dl/dd[1]/b/text()')#獲取運營時間的文本,xpath的邏輯為:div->dl->第一個dd->b
9 interval = info.xpath('./dl/dd[2]/text()')#獲取發(fā)車間隔的文本,xpath的邏輯為:div->dl->第二個dd
10 price = info.xpath('./dl/dd[3]/text()')#獲取票價信息的文本,xpath的邏輯為:div->dl->第三個dd
11 company = info.xpath('./dl/dd[4]/text()')#獲取汽車公司的文本,xpath的邏輯為:div->dl->第四個dd
12 up_times = info.xpath('./dl/dd[5]/text()')#獲取更新時間的文本,xpath的邏輯為:div->dl->第五個dd
13 all_stations_up = doc.xpath('//ul[@class='gj01_line_img JS-up clearfix']')#定位到相應(yīng)的div塊
14 for station in all_stations_up:
15 station_name = station.xpath('./li/a/text()')#遍歷取出該條線路上的站點名稱
16 all_stations_down = doc.xpath('//ul[@class='gj01_line_img JS-down clearfix']')#定位到返程線路相應(yīng)的div塊
17 for station in all_stations_down:
18 station_name = station.xpath('./li/a/text()')#遍歷取出該條線路上返程的站點名稱
19如果將獲取的文本都輸出(請自行添加相應(yīng)的print語句)運行結(jié)果如下:
20['老山公交場站(1)', '四惠樞紐站(27)']
21['5:00-23:00']
22['5:00-23:00']
23['發(fā)車間隔:未知']
24['票價信息:10公里以內(nèi)票價2元,每增加5公里以內(nèi)加價1元,最高票價6元']
25['汽車公司:北京公交集團(tuán)第六客運分公司']
26['更新時間:2015-04-05 03:32:16']
27['老山公交場站(1)', '老山南路東口(2)', '地鐵八寶山站(3)', '玉泉路口西(4)', '五棵松橋西(6)', '翠微路口(8)', '公主墳(9)', '軍事博物館(10)', '木樨地西(11)', '工會大樓(12)', '南禮士路(13)', '復(fù)興門內(nèi)(13)', '西單路口東(15)', '天安門西(16)', '天安門東(17)', '東單路口西(18)', '北京站口東(19)', '日壇路(20)', '永安里路口西(21)', '大北窯西(22)', '大北窯東(23)', '郎家園(23)', '四惠樞紐站(27)']
28['四惠樞紐站(27)', '八王墳西(24)', '郎家園(23)', '大北窯東(23)', '大北窯西(22)', '永安里路口西(21)', '日壇路(20)', '北京站口東(19)', '東單路口西(18)', '天安門東(17)', '天安門西(16)', '西單路口東(15)', '復(fù)興門內(nèi)(13)', '南禮士路(13)', '工會大樓(12)', '木樨地西(11)', '軍事博物館(10)', '公主墳(9)', '翠微路口(8)', '五棵松橋東(6)', '玉泉路口西(4)', '地鐵八寶山站(3)', '老山南路東口(2)', '老山公交場站(1)']
3.如何存儲獲取的數(shù)據(jù)?
數(shù)據(jù)存儲的載體通常有文件(例如csv,excel)和數(shù)據(jù)庫(例如mysql,MongoDB)。我們這里選擇了csv文件的形式,一方面是數(shù)據(jù)量不是太大,另一方面也不需要進(jìn)行數(shù)據(jù)庫安裝,只需將數(shù)據(jù)整理成dataframe的格式,直接調(diào)用pandas的to_csv
方法就可以將dataframe寫入csv文件中。主要代碼如下:
注:左右滑動查看詳細(xì)代碼
1#準(zhǔn)備一個存儲數(shù)據(jù)的字典
2df_dict = {
3 'line_name': [], 'line_url': [], 'line_start': [], 'line_stop': [],
4 'line_op_time': [], 'line_interval': [], 'line_price': [], 'line_company': [],
5 'line_up_times': [], 'line_station_up': [], 'line_station_up_len': [],
6 'line_station_down': [], 'line_station_down_len': []
7}
8#將上面獲取的數(shù)據(jù)寫入到字典中,注意這里只是示例,實際運行時候要將下面的代碼放到循環(huán)中,每解析一條線路就需要append一次。
9df_dict['line_name'].append(line_name)
10df_dict['line_url'].append(line_url)
11df_dict['line_start'].append(start_stop[0])
12df_dict['line_stop'].append(start_stop[1])
13df_dict['line_op_time'].append(op_times[0])
14df_dict['line_interval'].append(interval[0][5:])#為了把前面的文字“發(fā)車間隔”截掉,其余的類似
15df_dict['line_company'].append(company[0][5:])
16df_dict['line_price'].append(price[0][5:])
17df_dict['line_up_times'].append(up_times[0][5:])
18df_dict['line_station_up'].append(station_up_name)
19df_dict['line_station_up_len'].append(len(station_up_name))
20df_dict['line_station_down'].append(station_down_name)
21df_dict['line_station_down_len'].append(len(station_down_name))
22#將數(shù)據(jù)保存成csv文件
23df = pd.DataFrame(df_dict)
24df.to_csv('bjgj_lines_utf8.csv', encoding='utf-8', index=None)
4.看一看完整代碼?
以上我們分模擬請求,網(wǎng)頁解析,數(shù)據(jù)存儲3個步驟,學(xué)習(xí)了數(shù)據(jù)獲取的流程。實際運行過程中,還需要增加一些保證代碼“健壯性”的邏輯。例如,控制爬取的頻率,處理請求失敗的情況,處理不同的線路網(wǎng)頁結(jié)構(gòu)可能有差異的情況等等。本次的數(shù)據(jù)源沒有做很多反扒限制,因此前兩種情況我們可以不處理。至于第三種,有的路線會出現(xiàn)線路運營時間是空值的情況,需要進(jìn)行判斷。另外還可以增加一些爬蟲運行過程的提示信息,讓我們知道爬取進(jìn)度,當(dāng)然你也可以增加多線程,代理,ua切換等代碼,此處我們還用不上這些。完整的代碼可以在后臺回復(fù)“北京公交”進(jìn)行獲取。
數(shù)據(jù)預(yù)處理
在上一步獲取數(shù)據(jù)之后,我們就可以使用pandas進(jìn)行數(shù)據(jù)的分析工作。在正式的分析之前,數(shù)據(jù)預(yù)處理非常重要,它保證了數(shù)據(jù)的質(zhì)量,也為后續(xù)的工作奠定了重要的基礎(chǔ)。通常數(shù)據(jù)預(yù)處理在實際工作中都會占用比較多的時間。雖然我們這里的數(shù)據(jù)已經(jīng)足夠“結(jié)構(gòu)化”,但仍然不可避免存在一些問題。下面我們就來一探究竟。
5.如何讀取數(shù)據(jù)?
使用pandas提供的read_csv方法,該方法有很多可選的參數(shù),例如指定索引,列名,編碼等。對于本次數(shù)據(jù),直接使用默認(rèn)的即可。讀取的ori_data是dataframe類型,調(diào)用head方法可以輸出前5行的樣例數(shù)據(jù)。
1ori_data = pd.read_csv('bjgj_lines_utf8.csv')
2ori_data.head()
6.如何查看每一列數(shù)據(jù)的唯一值的個數(shù)?(如何查看有多少條線路)
可以使用dataframe的nunique方法,該方法輸出每一列有幾個唯一的值。
1ori_data.nunique()
2輸出結(jié)果如下:
3line_name 1986
4line_url 2002
5line_start 989
6line_stop 1123
7line_op_time 560
8line_interval 4
9line_price 126
10line_company 82
11line_up_times 650
12line_station_up 1928
13line_station_up_len 80
14line_station_down 1700
15line_station_down_len 80
16dtype: int64
由于線路很多,我們在原始網(wǎng)頁中很難發(fā)現(xiàn)是否會有重復(fù)的線路。但從上面觀察line_name和line_url兩個字段,line_name有1986個唯一值,line_url有2002個唯一值。說明line_name存在重復(fù):會有名稱相同的線路對應(yīng)不同的line_url。所以接下來我們需要進(jìn)行重復(fù)值的剔除。
7.如何找出重復(fù)的值?
出現(xiàn)了線路名稱的重復(fù),但卻有不同的line_url,究竟是確實是線路“重名”還是線路“重復(fù)”?我們需要看一下數(shù)據(jù)重復(fù)的具體情況。因此需要把重復(fù)的行都找出來看看??梢允褂胮andas的duplicated方法,它可以對dataframe的指定列查看是否重復(fù),返回True和False,代碼如下。
1d = ori_data.duplicated(subset=['line_name'])
2dup_data = ori_data[d]
3dup_data

這是所有重復(fù)出現(xiàn)過的line_name值,但并不是所有重復(fù)的值(例如22路重復(fù)出現(xiàn)過,但22路在結(jié)果中只有一條,不便于觀察除了名字之外是否還有其他字段的重復(fù))。為了找出所有重復(fù)的值(例如輸出所有22路的記錄),我們可以從原數(shù)據(jù)中取line_name是這些值的所有行,代碼和思路如下:
1#首先定義一個列表,每找出一行l(wèi)ine_name在上面范圍內(nèi)的,
2#就將這行加入列表,然后調(diào)用concat方法將列表拼接成#dataframe
3dup_lines = []
4for name in dup_data.line_name:
5 tmp_lines = ori_data[ori_data['line_name'] == name]
6 dup_lines.append(tmp_lines)
7 dup_data_all = pd.concat(dup_lines)
8dup_data_all

觀察dup_data_all,確實同一個線路名字存在重復(fù)的記錄,而且其余信息也是幾乎都相同的,這確認(rèn)了我們認(rèn)為的線路”重名“現(xiàn)象是不存在的。但同一條線路的信息具體以哪一個為準(zhǔn)呢?注意到有更新時間line_up_time字段,因此我們可以以最新時間的信息為準(zhǔn)。
8.如何對原數(shù)據(jù)剔除重復(fù)值?
這里考慮兩種思路。第一種,直接對原數(shù)據(jù)進(jìn)行操作,當(dāng)line_name存在重復(fù)時,保留最近更新時間的記錄。第二種,將原數(shù)據(jù)中的dup_data_all部分完全刪除,拼接上dup_data_all去除重復(fù)的部分。兩種思路都需要刪除line_name重復(fù)的記錄,保留一個時間最新的。pandas本身有drop_duplicates方法,使用keep=last或keep=first參數(shù)就可以指定保留的記錄。但在這之前我們需要將line_up_time轉(zhuǎn)換為pandas可以識別的時間類型,然后對其進(jìn)行排序。下面來看代碼:
注:左右滑動查看詳細(xì)代碼
1#方法1
2ori_data['line_up_times'] = pd.to_datetime(ori_data['line_up_times'], format='%Y-%m-%d %H:%M:%S')#使用to_datetime方法,指定format,將字符串轉(zhuǎn)換為pandas的時間類型。
3ori_data.sort_values(by=['line_name', 'line_up_times'], ascending=[True, True], inplace=True)#使用sort_values方法,對line_name和line_up_time排序
4drop_dup_line1 = ori_data.drop_duplicates(subset=['line_name'], keep='last')#由于是升序排列,所以keep=last就可以保留最新事件的記錄
5len(drop_dup_line1)#結(jié)果是1986
6
7方法2:
8dup_data_all['line_up_times'] = pd.to_datetime(dup_data_all['line_up_times'], format='%Y-%m-%d %H:%M:%S')#使用to_datetime方法,指定format,將字符串轉(zhuǎn)換為pandas的時間類型。
9dup_data_all.sort_values(by=['line_name', 'line_up_times'], ascending=[True, True], inplace=True)#使用sort_values方法,對line_name和line_up_time排序
10dup_data_all.drop_duplicates(subset=['line_name'], keep='last', inplace=True)#使用keep=last保留時間更新的記錄
11
12other_data = ori_data[~ori_data['line_name'].isin(dup_data_all.line_name)]#獲取原數(shù)據(jù)中剔除了重復(fù)線路的數(shù)據(jù):取名字不在dup_data_all的line_name集合中的記錄
13drop_dup_line2 = pd.concat([other_data, dup_data_all]) #拼接兩部分?jǐn)?shù)據(jù)
14len(drop_dup_line2)#結(jié)果是1986
如何比較兩種方法獲得的結(jié)果線路是否一致?我們可以用下面的代碼進(jìn)行。
1drop_dup_line2.sort_values(by=['line_name', 'line_up_times'], ascending=[True, True], inplace=True)#由于drop_dup_line1排序過,我們也對drop_dup_line2進(jìn)行相同規(guī)則的排序
2res = drop_dup_line1['line_name'].values.ravel() == drop_dup_line2['line_name'].values.ravel()#ravel()方法將數(shù)組展開,res是一個布爾值組成的ndarray數(shù)組,結(jié)果為true表示對應(yīng)元素相等
3res = [1 for i in res.flat if i]
4sum(res)#使用flat方法可以對ndarray進(jìn)行遍歷,sum看一下一共有多少個true,結(jié)果是1986,說明drop_dup_line1和drop_dup_line2對應(yīng)每一個位置的元素都相同
這樣對于重復(fù)數(shù)據(jù)的處理就結(jié)束了,我們使用drop_dup_line1來進(jìn)行下面的分析。
9.如何刪除地鐵線路?
雖然我們爬取的是公交路線,但程序運行過程中我也發(fā)現(xiàn)了地鐵的線路(其實地鐵也是廣義上的公交啦)。如果我們的目的是對純粹的公交線路進(jìn)行分析,就需要將地鐵的線路刪除。直觀的思路是剔除線路名稱中含有“地鐵”的記錄。
1is_subway = drop_dup_line1.line_name.str.contains('地鐵')#使用.str將其轉(zhuǎn)換為字符串就可以使用字符串的contains方法。
2subway_data = drop_dup_line1[is_subway]
3subway_data

從上圖左側(cè)可以看到subway_data的結(jié)果不僅僅有地鐵,還有一些地鐵有關(guān)的通勤線路,其實是公交。因此不能直接刪除line_name中含有“地鐵”的記錄,我們使用line_conpany中含有“地鐵”來區(qū)分,效果更好。代碼如下所示:
1is_subway2 = drop_dup_line1.line_company.str.contains('地鐵')
2subway_data2 = drop_dup_line1[is_subway2]
3subway_data2
結(jié)果如上圖右側(cè)所示,雖然最后一條也有一條“公交車路線”,但觀察整條記錄就會發(fā)現(xiàn)它其實是特殊的機場線地鐵。
到這里,你會不會想到根據(jù)線路名稱中是否含有“公交車路線”將地鐵線路剔除?我們可以試一試。但其實上面的圖已經(jīng)告訴了我們答案:有的公交線路是“接駁線”,并不含有“公交車路線”。
10.獲取刪除地鐵數(shù)據(jù)之后的全部數(shù)據(jù)
在drop_dup_line1的基礎(chǔ)上,篩選出線路名稱不在subway_data2中的線路名稱的記錄即可:
1clean_data = drop_dup_line1[~drop_dup_line1['line_name'].isin(subway_data2.line_name)]
2len(clean_data) #結(jié)果是1963,也就是北京的公交車一共有1963條線路
3
4clean_data3 = drop_dup_line1[drop_dup_line1.line_name.str.contains('公交車路線')]
5len(clean_data3) #通過是否含有“公交車線路”進(jìn)行篩選,結(jié)果是1955,應(yīng)該就是少了那些“接駁線”
如何比較clean_data和clean_data3。這個問題其實是如何求兩個dataframe差集的問題,我們轉(zhuǎn)化為求列表的差集,代碼和結(jié)果如下所示。
1list(set(clean_data.line_name.values).difference(set(clean_data3.line_name.values))) #找出在clean_data的line_name中但是不在clean_data3的line_name中的數(shù)據(jù)
2list(set(clean_data3.line_name.values).difference(set(clean_data.line_name.values))) #找出在clean_data3的line_name中但是不在clean_data的line_name中的數(shù)據(jù)

至此我們將重復(fù)數(shù)據(jù)進(jìn)行了刪除,并剔除了“地鐵”線路。但其實我們的數(shù)據(jù)預(yù)處理工作還沒有結(jié)束,我們還沒有觀察數(shù)據(jù)中是否含有缺失值。
11.如何查看數(shù)據(jù)集中的缺失值情況?
可以使用isnull().sum()方法查看。發(fā)現(xiàn)票價有230個缺失值。參見后面的圖片。對于缺失值我們需要在預(yù)處理階段對其進(jìn)行填充。考慮到票價數(shù)據(jù)本身不是純粹的價格數(shù)據(jù),而是一大串的文字描述,并且在公交的這種場景下,其實不同線路的票價差別不是很大,因此我們可以使用眾數(shù)對缺失值進(jìn)行填充。使用mode方法查看眾數(shù),使用fillna方法填補缺失值。
1#查看眾數(shù)的方法:
2clean_data.line_price.mode()#使用mode()方法查看line_price的眾數(shù)
3clean_data.line_price.value_counts()#使用value_counts()方法查看每一個取值出現(xiàn)的次數(shù),第一個也是眾數(shù)
4
5clean_data.line_price.fillna(clean_data.line_price.mode()[0], inplace=True)
6clean_data.isnull().sum()

至此我們基本完成了重復(fù)值和缺失值的處理。
總結(jié)
本文我們主要借助于北京公交數(shù)據(jù)的實例,學(xué)習(xí)了使用python進(jìn)行數(shù)據(jù)獲取和數(shù)據(jù)預(yù)處理的流程。內(nèi)容雖然簡單但不失完整性。數(shù)據(jù)獲取部分主要使用requests模擬了get請求,使用lxml進(jìn)行了網(wǎng)頁解析并將數(shù)據(jù)存儲到csv文件中。數(shù)據(jù)預(yù)處理部分我們進(jìn)行了重復(fù)值和缺失值的處理,但應(yīng)該說數(shù)據(jù)預(yù)處理并沒有完成。(比如我們可以對運營時間拆分成兩列,對站點名稱進(jìn)行清理等,如何進(jìn)行預(yù)處理工作與后續(xù)的分析緊密相關(guān))。文章的重點不在于例子的難度,而在于通過具體問題學(xué)習(xí)python中數(shù)據(jù)處理的方法。所處理的問題雖然有一定的特殊性,但也方便擴展到其他場景。希望對讀到這里的你有一定的幫助。讀者可以在后臺回復(fù)“北京公交”獲取本文的數(shù)據(jù)和爬取代碼,歡迎交流學(xué)習(xí)~以清凈心看世界。