Pandas.read_json()踩坑總結 & 原始碼初探
準備工作
環境依賴:Python 2.7
樣例資料(json檔案)
問題描述
通過Pandas.read_json(jsonFilePath)方法讀取json檔案時,會出現資料內容發生奇怪的轉變;Eg:假設樣例資料的檔名為data.json,則執行pd.read_json(data.json)後的結果以及各列資料的資料型別分別如下圖所示: 相較於原始資料集,經過該方法執行後的結果有兩處不一致的地方:第一,userId和telephone這兩列的資料型別由原本的String變成了int64;第二,userId欄位的值發生了變化。
原始碼剖析
接下來將從深入原始碼來探究這種情況發生的原因;pd.read_json()的原始碼及其該方法之間的呼叫時序分別如下所示:
當執行完資料解析後,我們已經得到DataFrame,那麼先彆著急往下看,在Debug下看看此時解析出來的結果如何:
對比發現,走到這一步時解析結果和欄位型別都和我們原始的資料集保持一致,所以可以肯定資料的解析邏輯是沒有問題的,那麼跟著原始碼繼續往下走,就來到frame軸型別轉化的過程;截止目前個人還是不太明白這層處理的意義是什麼;因為在對index和column進行資料型別轉化時,index列的型別是int64,而column的名字也都是字串從而導致嘗試型別轉化無效。所以對於這處有了解的環境補充和指教。因為這次的邏輯判斷可通過顯示的控制,且經過測試後發現執行對結果並無影響,因此在這裡不做過多的討論。
最後來看看parse的最後一個職能:嘗試對資料進行型別轉化。該方法在父類的實現只是簡單的異常捕獲,具體的處理邏輯在對應的子類中實現,在這裡看一下FrameParser類中方法_try_convert_types()的原始碼實現和對應的方法間呼叫時序: 在FrameParse中,會對資料進行兩次轉化嘗試:首先會嘗試進行日期型別的轉化,其次會對資料進行數值型別轉化。在日期型別的嘗試轉化中,是基於特殊命名的列資料進行處理,具體包括列名以“_at”,“_time”結尾、或者以“timestamp”開頭或者列名等於“modified”,“date”, “datetime”。因為這種處理對最終結果不會產生影響,所以在這裡不做過多討論。
跳過日期型別轉化後,就來到最後一步,資料的數值型別轉化嘗試。方法_process_converter()可以抽象的理解為一個數據轉化工具類,負責對資料集中的每一列資料按照指定的轉化規則進行轉化嘗試;該方法的第一個引數型別是一個方法,作用就是指定需要對資料列做哪種轉化。在這裡傳入父類的Parse._try_convert_data()方法,該方法的作用就是嘗試將資料轉化成數值型別;該方法的原始碼如下所示:
def _try_convert_data(self, name, data, use_dtypes=True,
convert_dates=True):
""" try to parse a ndarray like into a column by inferring dtype """
# don't try to coerce, unless a force conversion
if use_dtypes:
if self.dtype is False:
return data, False
elif self.dtype is True:
pass
else:
# dtype to force
dtype = (self.dtype.get(name)
if isinstance(self.dtype, dict) else self.dtype)
if dtype is not None:
try:
dtype = np.dtype(dtype)
return data.astype(dtype), True
except:
return data, False
if convert_dates:
new_data, result = self._try_convert_to_date(data)
if result:
return new_data, True
result = False
if data.dtype == 'object':
# try float
try:
data = data.astype('float64')
result = True
except:
pass
if data.dtype.kind == 'f':
if data.dtype != 'float64':
# coerce floats to 64
try:
data = data.astype('float64')
result = True
except:
pass
# do't coerce 0-len data
if len(data) and (data.dtype == 'float' or data.dtype == 'object'):
# coerce ints if we can
try:
new_data = data.astype('int64')
if (new_data == data).all():
data = new_data
result = True
except:
pass
# coerce ints to 64
if data.dtype == 'int':
# coerce floats to 64
try:
data = data.astype('int64')
result = True
except:
pass
return data, result
回顧一下文章一開始提到的例子,具體的呼叫方法為pd.read_json(filePath),則通過檢視原始碼的引數註解可知dtype預設為True,此時在方法_try_convert_data裡,由於user_dtypes和dtype同為True,convert_dates為False,所以程式碼直接跳過前面的邏輯判斷和時間型別轉化嘗試,直接進入數值型別的轉化嘗試中,讀完原始碼就可以看到資料會先嚐試轉化成float64型別,然後嘗試轉化為int64型別。通過一步步的Debug,也終於找到問題發生的根源:當代碼經過如下位置時,檢視一下此時對應的資料結果,如下圖所示: 看到這也就真想大白了:基於pd.read_json(path)這種寫法,底層會對每列資料進行數值型別轉化嘗試;又因為原始資料集中的userId是數值型別的字串,所以在將Object轉為float64時不會報錯,從而再經過後面的int型別的轉化,從而導致我們的最終看到的資料型別發生變化。我們可以看到telephone的型別發生了變化,但是資料型別缺沒有發生改變,而userId的內容都發生的奇怪的變化,這個原因又是什麼呢?
其實這個問題的本質和Python和Pandas就沒太大關係了,要弄清這個原因,就需要從計算機儲存浮點數的機制說起。因為Python的float型別是存在IEEE 745標準,因此這裡的float64即就是雙精度浮點數,所以在記憶體中,每個雙精度浮點數所佔用的總位數為64位,符號位佔1位,階數佔11位,尾數佔52位,那麼:252=4503599627370496,即雙精度浮點數的有效位數為16位。由於userId是一個19位的字串,所以在做型別轉化的時候會因為浮點數上溢現象導致資料失真;這就是為什麼經過astype(‘float64’)後資料內容發生變化的原因。
既然在實際應用中,無法保證、要求或者約束原始資料集,那麼如果規避這種因為浮點數上溢帶來的資料失真的情況呢?我們再來看一下那個資料型別轉化的方法_try_converty_data()的原始碼: 通過閱讀上下文的程式碼可知,user_dtyps恆為True,但是dtype可以讓使用者顯示的指定,只是如果不指定預設為True,從而導致浮點數儲存上溢的情況;當再次看到這裡的判斷邏輯不難發現,如果把dtype設定為False,則可以完全避免資料型別嘗試轉化的過程,從而可以保證資料的真實性和有效性;與此同時,通過查閱pd.read_josn()的引數註解可知道,dtype可以為Boolean型,同樣也可以為Dict型別,結合原始碼可以發現可以自定義指定需要轉化的資料列和資料型別;這樣我們也可以通過顯示的指定userId的轉化型別來規避這種上溢帶來的問題。上述兩種方法都是有效的,在這裡我以第二種為例,重修修改一下程式碼:
總結
在基於pandas處理資料時,尤其是通過讀取外部資料來源來做分析時,一定要注意資料型別的轉化問題,避免出現類似這種因為底層資料儲存溢位導致資料失真、或者資料型別變化導致的錯誤【Eg:pd.read_json(),pd.read_csv()】。
最後在順便吐槽一下,pandas底層的這個設定也太過於奇葩;應該將read_json()方法中的引數dtype的預設值設定為False,讓使用者去顯示的做型別轉化;而不應該為了凸顯在資料處理上的便利性,去容忍這種這種資料溢位的潛在Bug(亦或是pandas的開發者壓根沒意識到這個問題 ~~~ 哈哈 ^v^)。