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

如上圖所示,數(shù)據(jù)獲取分為請(qǐng)求,解析,存儲(chǔ)三個(gè)最主要的步驟。
1.如何用python模擬網(wǎng)絡(luò)請(qǐng)求?
使用request庫(kù)可以模擬不同的請(qǐng)求,例如requests.get()
模擬get請(qǐng)求,requests.post()
模擬post請(qǐng)求。必要的時(shí)候可以添加請(qǐng)求頭header,header通常包括user-agent,cookie,refer等信息,還可以增加請(qǐng)求參數(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)站比較簡(jiǎn)單,獲取網(wǎng)頁(yè)源碼的代碼如下:
1url = 'http://beijing./lines_all.html'
2text = requests.get(url).text
2.如何對(duì)網(wǎng)頁(yè)進(jìn)行解析?
python中提供了多種庫(kù)用于網(wǎng)頁(yè)解析,例如lxml,BeautifulSoup,pyquery等。每一個(gè)工具都有相應(yīng)的解析規(guī)則,但都是把HTML文檔當(dāng)做一個(gè)DOM樹,通過(guò)選擇器進(jìn)行節(jié)點(diǎn)和屬性的定位。本次我們使用lxml對(duì)網(wǎng)頁(yè)進(jìn)行解析,主要用到了xpath的語(yǔ)法。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é)合起來(lái)看。第一行代碼將上一步返回的HTML文本轉(zhuǎn)換為xpath可以解析的對(duì)象。第二行代碼定位到class=list
的div下面所有的li
標(biāo)簽,即右圖中的紅色框的部分,得到的是一個(gè)列表。從第三行開始對(duì)其進(jìn)行遍歷,處理每一個(gè)li
下面的a
標(biāo)簽。第4行取出a
標(biāo)簽下的文本,用到了xpath的text()
方法,對(duì)應(yīng)到第一個(gè)li
就是“北京1路公交車路線”,第5行取出a
標(biāo)簽下對(duì)應(yīng)的鏈接,用到了xpath的@href
取出a
標(biāo)簽下的href
屬性值。直接取都是列表的形式,所以需要用索引取出具體的值。
這樣我們就可以得到整個(gè)公交線路列表中的線路名稱和線路url。然后從線路url出發(fā),就可以獲取每條線路的具體信息。如下面代碼和圖片所示,雖然數(shù)據(jù)略多,但主要的邏輯和上面類似,可以查看代碼中的注釋。

注:左右滑動(dòng)查看詳細(xì)代碼
1url = 'http://beijing./xianlu_38753'#先以一個(gè)url為例,進(jìn)行頁(yè)面的分析
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()')#獲取起點(diǎn)站和終點(diǎn)站的文本,xpath的邏輯為:div->dl->dt->a
8 op_times = info.xpath('./dl/dd[1]/b/text()')#獲取運(yùn)營(yíng)時(shí)間的文本,xpath的邏輯為:div->dl->第一個(gè)dd->b
9 interval = info.xpath('./dl/dd[2]/text()')#獲取發(fā)車間隔的文本,xpath的邏輯為:div->dl->第二個(gè)dd
10 price = info.xpath('./dl/dd[3]/text()')#獲取票價(jià)信息的文本,xpath的邏輯為:div->dl->第三個(gè)dd
11 company = info.xpath('./dl/dd[4]/text()')#獲取汽車公司的文本,xpath的邏輯為:div->dl->第四個(gè)dd
12 up_times = info.xpath('./dl/dd[5]/text()')#獲取更新時(shí)間的文本,xpath的邏輯為:div->dl->第五個(gè)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()')#遍歷取出該條線路上的站點(diǎn)名稱
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()')#遍歷取出該條線路上返程的站點(diǎn)名稱
19如果將獲取的文本都輸出(請(qǐng)自行添加相應(yīng)的print語(yǔ)句)運(yùn)行結(jié)果如下:
20['老山公交場(chǎng)站(1)', '四惠樞紐站(27)']
21['5:00-23:00']
22['5:00-23:00']
23['發(fā)車間隔:未知']
24['票價(jià)信息:10公里以內(nèi)票價(jià)2元,每增加5公里以內(nèi)加價(jià)1元,最高票價(jià)6元']
25['汽車公司:北京公交集團(tuán)第六客運(yùn)分公司']
26['更新時(shí)間:2015-04-05 03:32:16']
27['老山公交場(chǎng)站(1)', '老山南路東口(2)', '地鐵八寶山站(3)', '玉泉路口西(4)', '五棵松橋西(6)', '翠微路口(8)', '公主墳(9)', '軍事博物館(10)', '木樨地西(11)', '工會(huì)大樓(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)', '工會(huì)大樓(12)', '木樨地西(11)', '軍事博物館(10)', '公主墳(9)', '翠微路口(8)', '五棵松橋東(6)', '玉泉路口西(4)', '地鐵八寶山站(3)', '老山南路東口(2)', '老山公交場(chǎng)站(1)']
3.如何存儲(chǔ)獲取的數(shù)據(jù)?
數(shù)據(jù)存儲(chǔ)的載體通常有文件(例如csv,excel)和數(shù)據(jù)庫(kù)(例如mysql,MongoDB)。我們這里選擇了csv文件的形式,一方面是數(shù)據(jù)量不是太大,另一方面也不需要進(jìn)行數(shù)據(jù)庫(kù)安裝,只需將數(shù)據(jù)整理成dataframe的格式,直接調(diào)用pandas的to_csv
方法就可以將dataframe寫入csv文件中。主要代碼如下:
注:左右滑動(dòng)查看詳細(xì)代碼
1#準(zhǔn)備一個(gè)存儲(chǔ)數(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ù)寫入到字典中,注意這里只是示例,實(shí)際運(yùn)行時(shí)候要將下面的代碼放到循環(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.看一看完整代碼?
以上我們分模擬請(qǐng)求,網(wǎng)頁(yè)解析,數(shù)據(jù)存儲(chǔ)3個(gè)步驟,學(xué)習(xí)了數(shù)據(jù)獲取的流程。實(shí)際運(yùn)行過(guò)程中,還需要增加一些保證代碼“健壯性”的邏輯。例如,控制爬取的頻率,處理請(qǐng)求失敗的情況,處理不同的線路網(wǎng)頁(yè)結(jié)構(gòu)可能有差異的情況等等。本次的數(shù)據(jù)源沒有做很多反扒限制,因此前兩種情況我們可以不處理。至于第三種,有的路線會(huì)出現(xiàn)線路運(yùn)營(yíng)時(shí)間是空值的情況,需要進(jìn)行判斷。另外還可以增加一些爬蟲運(yùn)行過(guò)程的提示信息,讓我們知道爬取進(jìn)度,當(dāng)然你也可以增加多線程,代理,ua切換等代碼,此處我們還用不上這些。完整的代碼可以在后臺(tái)回復(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í)際工作中都會(huì)占用比較多的時(shí)間。雖然我們這里的數(shù)據(jù)已經(jīng)足夠“結(jié)構(gòu)化”,但仍然不可避免存在一些問題。下面我們就來(lái)一探究竟。
5.如何讀取數(shù)據(jù)?
使用pandas提供的read_csv方法,該方法有很多可選的參數(shù),例如指定索引,列名,編碼等。對(duì)于本次數(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ù)的唯一值的個(gè)數(shù)?(如何查看有多少條線路)
可以使用dataframe的nunique方法,該方法輸出每一列有幾個(gè)唯一的值。
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
由于線路很多,我們?cè)谠季W(wǎng)頁(yè)中很難發(fā)現(xiàn)是否會(huì)有重復(fù)的線路。但從上面觀察line_name和line_url兩個(gè)字段,line_name有1986個(gè)唯一值,line_url有2002個(gè)唯一值。說(shuō)明line_name存在重復(fù):會(huì)有名稱相同的線路對(duì)應(yīng)不同的line_url。所以接下來(lái)我們需要進(jìn)行重復(fù)值的剔除。
7.如何找出重復(fù)的值?
出現(xiàn)了線路名稱的重復(fù),但卻有不同的line_url,究竟是確實(shí)是線路“重名”還是線路“重復(fù)”?我們需要看一下數(shù)據(jù)重復(fù)的具體情況。因此需要把重復(fù)的行都找出來(lái)看看。可以使用pandas的duplicated方法,它可以對(duì)dataframe的指定列查看是否重復(fù),返回True和False,代碼如下。
1d = ori_data.duplicated(subset=['line_name'])
2dup_data = ori_data[d]
3dup_data

這是所有重復(fù)出現(xiàn)過(guò)的line_name值,但并不是所有重復(fù)的值(例如22路重復(fù)出現(xiàn)過(guò),但22路在結(jié)果中只有一條,不便于觀察除了名字之外是否還有其他字段的重復(fù))。為了找出所有重復(fù)的值(例如輸出所有22路的記錄),我們可以從原數(shù)據(jù)中取line_name是這些值的所有行,代碼和思路如下:
1#首先定義一個(gè)列表,每找出一行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,確實(shí)同一個(gè)線路名字存在重復(fù)的記錄,而且其余信息也是幾乎都相同的,這確認(rèn)了我們認(rèn)為的線路”重名“現(xiàn)象是不存在的。但同一條線路的信息具體以哪一個(gè)為準(zhǔn)呢?注意到有更新時(shí)間line_up_time字段,因此我們可以以最新時(shí)間的信息為準(zhǔn)。
8.如何對(duì)原數(shù)據(jù)剔除重復(fù)值?
這里考慮兩種思路。第一種,直接對(duì)原數(shù)據(jù)進(jìn)行操作,當(dāng)line_name存在重復(fù)時(shí),保留最近更新時(shí)間的記錄。第二種,將原數(shù)據(jù)中的dup_data_all部分完全刪除,拼接上dup_data_all去除重復(fù)的部分。兩種思路都需要?jiǎng)h除line_name重復(fù)的記錄,保留一個(gè)時(shí)間最新的。pandas本身有drop_duplicates方法,使用keep=last或keep=first參數(shù)就可以指定保留的記錄。但在這之前我們需要將line_up_time轉(zhuǎn)換為pandas可以識(shí)別的時(shí)間類型,然后對(duì)其進(jìn)行排序。下面來(lái)看代碼:
注:左右滑動(dòng)查看詳細(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的時(shí)間類型。
3ori_data.sort_values(by=['line_name', 'line_up_times'], ascending=[True, True], inplace=True)#使用sort_values方法,對(duì)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的時(shí)間類型。
9dup_data_all.sort_values(by=['line_name', 'line_up_times'], ascending=[True, True], inplace=True)#使用sort_values方法,對(duì)line_name和line_up_time排序
10dup_data_all.drop_duplicates(subset=['line_name'], keep='last', inplace=True)#使用keep=last保留時(shí)間更新的記錄
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排序過(guò),我們也對(duì)drop_dup_line2進(jìn)行相同規(guī)則的排序
2res = drop_dup_line1['line_name'].values.ravel() == drop_dup_line2['line_name'].values.ravel()#ravel()方法將數(shù)組展開,res是一個(gè)布爾值組成的ndarray數(shù)組,結(jié)果為true表示對(duì)應(yīng)元素相等
3res = [1 for i in res.flat if i]
4sum(res)#使用flat方法可以對(duì)ndarray進(jìn)行遍歷,sum看一下一共有多少個(gè)true,結(jié)果是1986,說(shuō)明drop_dup_line1和drop_dup_line2對(duì)應(yīng)每一個(gè)位置的元素都相同
這樣對(duì)于重復(fù)數(shù)據(jù)的處理就結(jié)束了,我們使用drop_dup_line1來(lái)進(jìn)行下面的分析。
9.如何刪除地鐵線路?
雖然我們爬取的是公交路線,但程序運(yùn)行過(guò)程中我也發(fā)現(xiàn)了地鐵的線路(其實(shí)地鐵也是廣義上的公交啦)。如果我們的目的是對(duì)純粹的公交線路進(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)的通勤線路,其實(shí)是公交。因此不能直接刪除line_name中含有“地鐵”的記錄,我們使用line_conpany中含有“地鐵”來(lái)區(qū)分,效果更好。代碼如下所示:
1is_subway2 = drop_dup_line1.line_company.str.contains('地鐵')
2subway_data2 = drop_dup_line1[is_subway2]
3subway_data2
結(jié)果如上圖右側(cè)所示,雖然最后一條也有一條“公交車路線”,但觀察整條記錄就會(huì)發(fā)現(xiàn)它其實(shí)是特殊的機(jī)場(chǎng)線地鐵。
到這里,你會(huì)不會(huì)想到根據(jù)線路名稱中是否含有“公交車路線”將地鐵線路剔除?我們可以試一試。但其實(shí)上面的圖已經(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) #通過(guò)是否含有“公交車線路”進(jìn)行篩選,結(jié)果是1955,應(yīng)該就是少了那些“接駁線”
如何比較clean_data和clean_data3。這個(gè)問題其實(shí)是如何求兩個(gè)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í)我們的數(shù)據(jù)預(yù)處理工作還沒有結(jié)束,我們還沒有觀察數(shù)據(jù)中是否含有缺失值。
11.如何查看數(shù)據(jù)集中的缺失值情況?
可以使用isnull().sum()方法查看。發(fā)現(xiàn)票價(jià)有230個(gè)缺失值。參見后面的圖片。對(duì)于缺失值我們需要在預(yù)處理階段對(duì)其進(jìn)行填充。考慮到票價(jià)數(shù)據(jù)本身不是純粹的價(jià)格數(shù)據(jù),而是一大串的文字描述,并且在公交的這種場(chǎng)景下,其實(shí)不同線路的票價(jià)差別不是很大,因此我們可以使用眾數(shù)對(duì)缺失值進(jìn)行填充。使用mode方法查看眾數(shù),使用fillna方法填補(bǔ)缺失值。
1#查看眾數(shù)的方法:
2clean_data.line_price.mode()#使用mode()方法查看line_price的眾數(shù)
3clean_data.line_price.value_counts()#使用value_counts()方法查看每一個(gè)取值出現(xiàn)的次數(shù),第一個(gè)也是眾數(shù)
4
5clean_data.line_price.fillna(clean_data.line_price.mode()[0], inplace=True)
6clean_data.isnull().sum()

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