1. 程式人生 > >Pandas.read_json()踩坑總結 & 原始碼初探

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()的原始碼及其該方法之間的呼叫時序分別如下所示: 這裡寫圖片描述

這裡寫圖片描述   通過這段原始碼可以看出,read_json方法主要做了三件事:首先基於給定的引數做校驗,然後獲取指定url或流中的資料資訊轉化為jsonStr,最後一步對該jsonStr進行解析。使用者可顯示的通過typ欄位來指定解析結果的型別(DataFrame or Series)。解析邏輯所對應的物件模型如下所示: 這裡寫圖片描述   由於Series的解析邏輯比較簡單,且實際工作中直接基於DataFrame的操作比較多,因此這裡主要對jsonStr解析成DataFrame的過程做進一步的梳理。在第三步資料解析的過程中,FrameParser.parse()方法的本質其實是呼叫了父類Parse的parse方法,該方法的職能有三個:首先將jsonStr解析成DataFrame資料結構;其次對解析結果的軸做資料型別轉化;最後嘗試對資料進行型別轉化
。原始碼及對應的方法間呼叫時序如下圖所示: 這裡寫圖片描述 這裡寫圖片描述   在解析jsonStr時,首先會根據引數numpy來判斷是否需要將資料反序列化為numpy陣列型別;這個反序列化的過程是通過Pandas內部封裝的json工具類的loads方法來實現的;然後將反序列化後的Dict物件經過DataFrame類進行資料初始化,從而得到該jsonFile所對應的DataFrame資料結構。由於_parse_no_numpy() 和_parse_numpy()這兩個方法的原理類似,這裡以_parse_numpy()為例,看一下對應的原始碼: 這裡寫圖片描述   注意這裡的寫法:父類Parse中是沒有_parse_no_numpy() 和_parse_numpy()這兩個方法的,也就是說是在父類呼叫子類的方法。其實不同於Java這類程式語言,在Python中需要從物件生成的角度來看待這個問題;因為此時的Parse類就是FrameParser,所以self._parse_no_numpy()的呼叫本質就是其實現類自身的方法,所以就有了這種看似奇怪的父調子寫法。

  當執行完資料解析後,我們已經得到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^)。