一場pandas與SQL的巔峰大戰(二)

上一篇中,我們對比了pandas與SQL常見的一些操作,我們的例子雖然是以MySQL為基礎的,但換作其他的數據庫軟件,也一樣適用。工作中除了MySQL,也經常會使用Hive SQL,相比之下,後者有更為強大和豐富的函數。本文將延續上一篇文章的風格和思路,繼續對比Pandas與SQL,一方面是對上文的補充,另一方面也繼續深入學習一下兩種工具。方便起見,本文采用hive環境運行SQL,使用jupyter lab運行pandas。關於hive的安裝和配置,我在之前的文章提到過,如果你覺得比較困難,可以考慮使用postgreSQL,它比MySQL支持更多的函數(不夠代碼可能需要進行一定的改動)。而jupyter lab和jupyter notebook功能相同,界面相似,完全可以用notebook代替。希望本文可以幫助各位讀者在工作中進行pandas和Hive SQL的快速轉換。

在公眾號“超哥的雜貨鋪”後臺回覆“對比二”可以獲取本文的PDF版本以及全部的數據和代碼。

數據概況

數據上,我們還是使用上一篇中虛擬的數據,在ts的格式上有些小改動,在使用之前同樣需要先用read_csv的方式讀取,具體可以參考上篇文章。本文不做這一步的演示。hive方面我們新建了一張表,並把同樣的數據加載進了表中,直接使用即可。

一場pandas與SQL的巔峰大戰(二)

一場pandas與SQL的巔峰大戰(二)

開始學習

一、字符串的截取

對於原始數據集中的一列,我們常常要截取其字串作為新的列來使用。例如我們想求出每一條訂單對應的日期。需要從訂單時間ts或者orderid中截取。在pandas中,我們可以將列轉換為字符串,截取其子串,添加為新的列。代碼如下圖左側所示,我們使用了.str將原字段視為字符串,從ts中截取了前10位,從orderid中截取了前8位。經驗表明有時在.str之前需要加上astype,能夠避免不必要的麻煩。兩種寫法供參考。

對於字符串截取的操作,Hive SQL中有substr函數,它在MySQL和Hive中的用法是一樣的substr(string A,int start,int len)表示從字符串A中截取起始位置為start,長度為len的子串,其中起始位置從1開始算。實現上面效果的代碼如下:

一場pandas與SQL的巔峰大戰(二)

圖片中的代碼:

#python
import pandas as pd
order = pd.read_csv('order.csv', names=['id', 'ts', 'uid', 'orderid', 'amount'])
order.head()


order['dt'] = order['ts'].str[:10]
order.head()

order['dt2'] = order['orderid'].astype(str).str[:8]
order.head()

#Hive SQL
select *, substr(ts, 1, 10) as dt, substring(orderid, 1, 8) as dt2
from t_order;

二、字符串匹配

這一節我們來研究提取包含特定字符的字段。沿用上一節的寫法,在pandas中我們可以使用字符串的contains,extract,replace方法,支持正則表達式。而在hive SQL中,既有簡易的Like關鍵字匹配特定的字符,也可以使用regexp_extract,regexp_replace這兩個函數更靈活地實現目標。接下來我們舉例說明。

  1. 假設要實現篩選訂單時間中包含“08-01”的訂單。pandas和SQL代碼如下所示,注意使用like時,%是通配符,表示匹配任意長度的字符。
一場pandas與SQL的巔峰大戰(二)

圖片中的代碼:

#python
order_08_01 = order[order['ts'].astype(str).str.contains('08-01')]
order_08_01

#Hive SQL
select * 
from t_order
where ts like "%08-01%"; 

2.假設要實現提取ts中的日期信息(前10位),pandas裡支持正則表達式的extract函數,而hive裡除了前文提到的substr函數可以實現外,這裡我們可以使用regexp_extract函數,通過正則表達式實現。

一場pandas與SQL的巔峰大戰(二)

圖片中的代碼

#python
order['dt3'] = order['ts'].astype(str).str.extract('(\d{4}-\d{2}-\d{2}).*')
#這個正則表達式表示"4位數字橫槓兩位數字橫槓兩位數字",後面是任意字符,
#我們提取的目標要放在小括號裡
order.head()

#Hive SQL
select *, regexp_extract(ts, '(\\d{4}-\\d{2}-\\d{2}).*', 1) as dt3
from t_order;
#我們的目標同樣是在小括號裡,1表示取第一個匹配的結果
 

3.假設我們要去掉ts中的橫槓,即替換ts中的“-”為空,在pandas中可以使用字符串的replace方法,hive中可以使用regexp_replace函數。代碼如下:

一場pandas與SQL的巔峰大戰(二)

圖片中代碼:

#python
order['dt4'] = order['ts'].astype(str).str.replace('-', '')
order.head()

#Hive SQL
select *, regexp_replace(ts, '-', '') as dt4
from t_order;

三、帶條件的計數:count(distinct case when …end)

我們在上一篇文章中分別討論過分組聚合和case操作。實際中,經常會遇到二者嵌套的情況,例如,我們想統計:ts中含有‘2019-08-01’的不重複訂單有多少,ts中含有‘2019-08-02’的不重複訂單有多少,這在Hive SQL中比較容易,代碼和得到的結果為:

select count(distinct case when ts like '%2019-08-01%' then orderid end) as 0801_cnt,
count(distinct case when ts like '%2019-08-02%' then orderid end) as 0802_cnt
from t_order;
#運行結果:
5 11

你當然可以直接對日期進行分組,同時計算所有日期的訂單數,此處我們僅僅是為了演示兩種操作的結合。

pandas中實現這個問題可能比較麻煩,也可能有很多不同的寫法。這裡說一下我的思路和實現方式。

我定義了兩個函數,第一個函數給原數據增加一列,標記我們的條件,第二個函數再增加一列,當滿足條件時,給出對應的orderid,然後要對整個dataframe應用這兩個函數。對於我們不關心的行,這兩列的值都為nan。第三步再進行去重計數操作。代碼和結果如下:

#第一步:構造一個輔助列
def func_1(x):
 if '2019-08-01' in x['ts']:
 return '2019-08-01'#這個地方可以返回其他標記
 elif '2019-08-02' in x['ts']:
 return '2019-08-02'
 else:
 return None

#第二步:將符合條件的order作為新的一列
def func_2(x):
 if '2019-08-01' in x['ts']:
 return str(x['orderid'])
 elif '2019-08-02' in x['ts']:
 return str(x['orderid'])
 else:
 return None

#應用兩個函數,查看結果
#注意這裡必須加上axis=1,你可以嘗試下不加會怎樣
order['cnt_condition'] = order.apply(func_1, axis=1)
order['cnt'] = order.apply(func_2, axis=1)
order[order['cnt'].notnull()]

#進行分組計數
order.groupby('cnt_condition').agg({'cnt': 'nunique'})
一場pandas與SQL的巔峰大戰(二)


可以看到,同樣得到了5,11的結果。如果你有其他更好的實現方法,歡迎一起探討交流。

四、窗口函數 row_number

hive中的row_number函數通常用來分組計數,每組內的序號從1開始增加,且沒有重複值。比如我們對每個uid的訂單按照訂單時間倒序排列,獲取其排序的序號。實現的Hive SQL代碼如下,可以看到,每個uid都會有一個從1開始的計數,這個計數是按時間倒序排的。

select *, row_number() over (partition by uid order by ts desc) as rk
from t_order;
一場pandas與SQL的巔峰大戰(二)

pandas中我們需要藉助groupby和rank函數來實現同樣的效果。改變rank中的method參數可以實現Hive中其他的排序,例如dense,rank等。

#由於我們的ts字段是字符串類型,先轉換為datetime類型
order['ts2'] = pd.to_datetime(order['ts'], format='%Y-%m-%d %H:%M:%S')

#進行分組排序,按照uid分組,按照ts2降序,序號默認為小數,需要轉換為整數
#並添加為新的一列rk
order['rk'] = order.groupby(['uid'])['ts2'].rank(ascending=False, method='first').astype(int)

#為了便於查看rk的效果,對原來的數據按照uid和時間進行排序,結果和SQL一致
order.sort_values(['uid','ts'], ascending=[True, False])
一場pandas與SQL的巔峰大戰(二)

五、窗口函數 lag,lead

lag和lead函數也是Hive SQL中常用的窗口函數,他們的格式為:

lag(字段名,N) over(partition by 分組字段 order by 排序字段 排序方式) 
lead(字段名,N) over(partition by 分組字段 order by 排序字段 排序方式) 

lag函數表示,取分組排序之後比該條記錄序號小N的對應記錄的指定字段的值。lead剛好相反,是比當前記錄大N的對應記錄的指定字段值。我們來看例子。

一場pandas與SQL的巔峰大戰(二)

例子中的lag表示分組排序後,前一條記錄的ts,lead表示後一條記錄的ts。不存在的用NULL填充。

對應的代碼為:

select *, 
lag(ts, 1) over (partition by uid order by ts desc) as lag,
lead(ts, 1) over (partition by uid order by ts desc) as lead
from t_order;

pandas中我們也有相應的shift函數來實現這樣的需求。shift的參數為負數時,表示lag,為正數時,表示lead。

一場pandas與SQL的巔峰大戰(二)

代碼如下:

order['lag'] = order.groupby(['uid'])['ts2'].shift(-1)
order['lead'] = order.groupby(['uid'])['ts2'].shift(1)

#依然是為了看效果,對原來的數據按照uid和時間進行排序,結果和SQL一致
order.sort_values(['uid','ts'], ascending=[True, False])

六、列轉行,collect_list

在我們的數據中,一個uid會對應多個訂單,目前這多個訂單id是分多行顯示的。現在我們要做的是讓多個訂單id顯示在同一行,用逗號分隔開。在pandas中,我們採用的做法是先把原來orderid列轉為字符串形式,並在每一個id末尾添加一個逗號作為分割符,然後採用字符串相加的方式,將每個uid對應的字符串類型的訂單id拼接到一起。代碼和效果如下所示。為了減少干擾,我們將order數據重新讀入,並設置了pandas的顯示方式。

一場pandas與SQL的巔峰大戰(二)

可以看到,同一個uid對應的訂單id已經顯示在同一行了,訂單id之間以逗號分隔。

在Hive中實現同樣的效果要方便多了,我們可以使用collect_set/collect_list函數,,二者的區別在於前者在聚合時會進行去重,別忘了加上group by。

select uid, collect_set(orderid) as order_list
from t_order
group by uid
;
一場pandas與SQL的巔峰大戰(二)

可以看出hive實現的效果中,將同一個uid的orderid作為一個“數組”顯示出來。雖然和pandas實現的效果不完全一樣,但表達的含義是一致的。我沒有找到pandas實現這樣數組形式比較好的方法,如果你知道,歡迎一起交流.另外,pandas在聚合時,如何去重,也是一個待解決的問題。

七 行轉列 later view explode

行轉列的操作在Hive SQL中有時會遇到,可以理解為將上一小節的結果還原為每個orderid顯示一行的形式。hive中有比較方便的explode函數,結合lateral view,可以很容易實現。代碼和效果如下:

-- 使用上一節的結果,定義為tmp表,後面可以直接用
with tmp as 
(
select uid, collect_set(orderid) as order_list
from t_order
group by uid
)

select uid, o_list
from tmp lateral view explode(order_list) t as o_list;
一場pandas與SQL的巔峰大戰(二)

我們來看在pandas中的實現。目標是把上一節合併起來的用逗號分隔的數組拆分開。這裡給出一個參考鏈接:https://blog.csdn.net/sscc_learning/article/details/89473151。

首先我們要把groupby的結果索引重置一下,然後再進行遍歷,和賦值,最後將每一個series拼接起來。我採用的是鏈接中的第一種方式。由於是遍歷,效率可能比較低下,讀者可以嘗試下鏈接裡的另一種方式。我先給出我的代碼:

order_group = order_group.reset_index()
order_group

order_group1 = pd.concat([pd.Series(row['uid'], row['orderid'].split(',')) for _ , row in order_group.iterrows()]).reset_index()
order_group1

這樣的結果中會有一個空行,這是因為用逗號分隔的時候,最後一個元素為空。後續可以使用我們之前學習的方法進行過濾或刪除。這裡省略這一步驟。

一場pandas與SQL的巔峰大戰(二)

八、數組元素解析

這一小節我們引入一個新的數據集,原因是我想分享的內容,目前的數據集不能夠體現,哈哈。下面是在Hive和pandas中查看數據樣例的方式。我們的目標是將原始以字符串形式存儲的數組元素解析出來。

一場pandas與SQL的巔峰大戰(二)

一場pandas與SQL的巔峰大戰(二)

先來看pandas中如何實現,這裡我們需要用到literal_eval這個包,能夠自動識別以字符串形式存儲的數組。我定義了一個解析函數,將arr列應用該函數多次,解析出的結果作為新的列,代碼如下:

一場pandas與SQL的巔峰大戰(二)

這裡需要注意解析出的結果是object類型的,如果想讓它們參與數值計算,需要再轉換為int類型,可以在解析的時候增加轉換的代碼。

new_data['arr_1'] = new_data.arr.apply(extract_num, args=(0,)).astype(int)

回到Hive SQL,實現起來比較容易。我們可以通過split函數將原來的字符串形式變為數組,然後依次取數組的元素即可,但是要注意使用substr函數處理好前後的中括號,代碼如下:

一場pandas與SQL的巔峰大戰(二)

可以看到最終我們得到的結果是字符串的形式,如果想要得到數值,可以再進行一步截取。

一場pandas與SQL的巔峰大戰(二)

可以看到,我們這裡得到的依然是字符串類型,和pandas中的強制轉換類似,hive SQL中也有類型轉換的函數cast,使用它可以強制將字符串轉為整數,使用方法如下面代碼所示。

一場pandas與SQL的巔峰大戰(二)

小結

本文涉及的操作概括如下表所示,雖然內容沒有上篇文章多,但相對難度還是比上篇高一些。

一場pandas與SQL的巔峰大戰(二)

如果你認真讀了本文,會發現有一些情況下,Hive SQL比pandas更方便,為了達到同樣的效果,pandas可能要用一種全新的方式來實現。實際工作中,如果數據存在數據庫中,使用SQL語句來處理還是方便不少的,尤其是如果數據量大了,pandas可能會顯得有點吃力。本文的出發點僅僅是對比兩者的操作,方便從兩個角度理解常見的數據處理手段,也方便工作中的轉換查閱,不強調孰優孰劣。對於文中遺留的不是很完美的地方,如果您想到了好的方案,歡迎一起探討交流~文中用到的數據和代碼我已經打包整理好,在公眾號“超哥的雜貨鋪”後臺回覆“

對比二”即可獲得,祝您練習愉快!


分享到:


相關文章: