True가 1.0이 되었다 - pandas가 컬럼 타입을 정하는 방식
pandas의 컬럼 타입 변환 주의보
pandas라는 데이터 분석에 자주 쓰이는 파이썬 패키지를 사용하다가, 생성해둔 DataFrame의 특정 컬럼의 데이터가 변경이 되는 현상을 마주했다. 특히 엑셀에서 데이터를 불러왔을 때 문제였다.
이번 글에서는 pandas가 파일을 읽어 컬럼 타입을 정하는 과정을 pandas 내부 코드에서 csv와 excel로 나누어 확인하고, 어떻게 타입을 명시적으로 지정할 수 있는지 작성한다.
글을 이해하기 위한 prerequisite는 ‘pandas에 대한 기본 문법 및 pandas로 데이터를 다뤄본 경험’이다.
실습은 pandas 3.0.2 기준으로 하였으나 2.x에서도 동일하게 확인할 수 있다.
문제 정의
이번 글에서 제기하는 문제는 다음과 같다.
엑셀 파일 사용 시, bool 데이터가 있는 컬럼에 셀이 비어 있는 경우를 DataFrame로 만들면 해당 컬럼은 float 타입의 컬럼으로 변환되어 True/False 값 또한 float으로 변환된다.
말인 즉슨, 결측치가 존재하는 행이 있다면 bool 타입의 컬럼에 있는 값들이 DataFrame에서는 보장이 되지 않았다는 것이다.
데이터로 직접 확인해보기
csv와 excel 데이터 준비
파이썬으로 데이터 분석을 할 때, csv나 excel의 데이터를 이용해서 pandas의 DataFrame으로 데이터를 확인할 수 있다.
다음의 csv가 있다고 하자. 아주 간단한 형태의 csv이다.
plaintext_data,int_data,bool_data ,1,False a,2,True b,3,
text_data,int_data,bool_data라는 컬럼이 존재- 1행 데이터에는
text_data에 값이 없다. 즉, 결측치가 존재 - 3행에는
bool_data에 값이 없다. 이 또한 결측치가 존재한다는 의미
그리고 이 csv를 이용해서 excel 파일도 하나 만들었다. 즉, 데이터는 그대로 두며 포맷만 다른 파일을 만든다는 것이다.
pythonimport pandas as pd df = pd.read_csv("./test.csv") # csv를 DataFrame으로 불러와서 df.to_excel("test.xlsx", index=False) # excel 파일로 저장
pandas.DataFrame으로 불러오기
각 파일의 데이터를 DataFrame(이하 ‘df’)으로 변환한다. 그리고 각각의 df를 출력하여 값이 어떻게 나오는지 확인한다.
pythonimport pandas as pd df_csv = pd.read_csv("./test.csv") print("1. csv 데이터 읽기") print(df_csv) # 이 df(dataFrame) 출력 값은? df_xlsx = pd.read_excel("./test.xlsx") # 해당 엑셀을 다시 dataFrame으로 불러온다. print("2. excel 데이터 읽기") print(df_xlsx) # 이 때의 df(dataFrame) 출력 값은?
csv로 만든 df는 원래 작성한 boolean 값과 함께 ‘NaN’이라는, pandas에서는 결측으로 표현할 수 있는 값이 함께 출력되었다. 처음에 만든 csv의 2행 2열의 셀에는 값을 작성하지 않았기 때문이다.
하지만 xlsx로 만든 df는 True/False 대신 1.0 / 0.0 과 같은 float형 데이터로 치환이 되어 출력이 되었다.
pythonprint("3. 각 컬럼의 타입은?") print("3-1. csv로 만든 df") print(df_csv.dtypes) print("3-2. xlsx로 만든 df") print(df_xlsx.dtypes)
컬럼의 타입을 확인해도 csv로 만든 df의 경우에는 object, xlsx로 만든 df의 경우에는 float64로 출력된다.
object 타입이란?
numpy에서 정의한, 파이썬 객체의 포인터를 담은 타입으로, int64 , str , float64 등의 명확한 타입으로 나타낼 수 없는 경우나 다양한 타입을 한 컬럼에 나타내야 할 때 사용한다.
왜 이런 현상이 일어날까?
CSV - DataFrame을 만들기 위한 배열 별 타입 추론
pandas.read_csv 는 내부적으로 TextParser 라는 클래스를 가지고 데이터를 파싱하여 DataFrame을 생성한다. csv는 컬럼 정보를 갖고 있지 않기 때문에 컬럼의 타입을 추론해야 하는데 이 때 사용하는 메서드가 _infer_types 이다. 다음은 해당 메서드의 일부 코드이다.
pandas/io/parsers/base_parser.py
pythondef _infer_types(self, values, na_values, no_dtype_specified, try_num_bool=True): # 파라미터로 받는 values는 numpy array 타입이다. # 즉, 컬럼 한 줄에 있는 모든 데이터 값의 나열 # ... 결측 마스킹 생략 # 1단계: 원본이 object 타입이고 변환 플래그가 True일 때 # 숫자 변환을 먼저 시도한다 if try_num_bool and is_object_dtype(values.dtype): try: result, result_mask = lib.maybe_convert_numeric( values, na_values, False, # ... ) except (ValueError, TypeError): # 숫자로 못 바꾸면 원본(object)을 그대로 둔다 # 여기서 sanitize_objects 메서드 내에서 결측치를 NaN으로 변경 na_count = parsers.sanitize_objects(values, na_values) result = values else: # ... dtype_backend 지정 시 IntegerArray/BooleanArray로 감싸는 코드 생략 na_count = isna(result).sum() else: result = values # ... 생략 # 2단계: 1단계에서 object로 남았을 때만 불리언 변환을 시도한다 if ( result.dtype == np.object_ and try_num_bool and (len(result) == 0 or not isinstance(result[0], int)) ): result, bool_mask = libops.maybe_convert_bool( np.asarray(values), true_values=self.true_values, false_values=self.false_values, ... ) # ... 생략 return result, na_count
True / False는 들어올 때 string 값으로 들어온다. 이는 1단계에서 숫자 타입으로 변환하는 데 실패하기 때문에 string 값으로 그대로 둔다.
결측치에 대한 값은 except 구문 내에 있는 parsers.sanitize_objects 함수에 의해 NaN 으로 치환 된다. 여기서의 NaN은 np.nan타입인데 이 값의 타입은 numpy.float64에 해당한다.
그리고 2단계 코드에서 비로소 True/ False의 타입이 string에서 boolean으로 변경되어 그대로 살아있는 것처럼 된다.
따라서 csv에서 읽어들인 bool_data컬럼은 최종적으로 다양한 형을 동시에 담을 수 있는 object 타입으로 결정이 된다.
Excel - openpyxl의 변환 과정이 존재
파이썬을 사용한다면 excel 파일의 데이터를 다룰 때 openpyxl 이라는 패키지를 주로 접하게 된다. pandas.read_excel는 이 패키지를 사용하여 엑셀 파일을 읽는다.
openpyxl이란?
파이썬에서 xlsx 파일을 읽고 쓰는 라이브러리. xlsx는 XML 문서들을 묶은 zip 파일이며, openpyxl은 이를 파싱해 시트와 셀을 파이썬 객체로 다룰 수 있게 한다.
xlsx은 엑셀 파일의 확장자로 알려져 있지만, 실제로는 XML의 묶음이다. 예제의 test.xlsx파일은 다음의 XML로 표현이 된다.
xml<row r="2" spans="1:3"> <c r="B2"><v>1</v></c> <c r="C2" t="b"><v>0</v></c> <!-- t="b"가 boolean 값을 나타냄 --> </row> <row r="3" spans="1:3"> <c r="A3" t="s"><v>3</v></c> <c r="B3"><v>2</v></c> <c r="C3" t="b"><v>1</v></c> <!-- t="b"가 boolean 값을 나타냄 --> </row> <row r="4" spans="1:3"> <c r="A4" t="s"><v>4</v></c> <c r="B4"><v>3</v></c> </row>
그리고 위의 XML을 이용해서 셀 별로 데이터의 타입을 확인한다. 이는 openpyxl에 있는 parse_cell 이라는 메서드에서 확인이 가능하다.
pythondef parse_cell(self, element): data_type = element.get('t', 'n') # XML의 t 속성이 셀 타입 # ... value = element.findtext(VALUE_TAG, None) or None # ... if data_type == 'n': value = _cast_number(value) # ... elif data_type == 's': value = self.shared_strings[int(value)] elif data_type == 'b': # 데이터가 boolean을 나타내는 문자열이라면, 실제 boolean 값으로 변환 value = bool(int(value)) elif data_type == 'd': value = from_ISO8601(value)
이렇게 얻은 각 cell의 value를 다시 pandas의 _convert_cell 메서드로 변경한다. 아래의 코드가 해당 메서드다.
pythondef _convert_cell(self, cell) -> Scalar: if cell.value is None: return "" # 빈 셀은 빈 문자열로 elif cell.data_type == TYPE_ERROR: return np.nan elif cell.data_type == TYPE_NUMERIC: val = int(cell.value) if val == cell.value: return val # 소수점 없으면 int로 return float(cell.value) return cell.value # 나머지는 그대로 통과
True / False는 boolean 값이라서 그대로 통과한다. 단, 결측치에 해당하는 값은 openpyxl에서 None으로 치환했으므로 이를 받아 빈 문자열로 return한다.
각각 변환의 과정을 마친 각 cell을 array로 조합하여, 여기서도 _infer_types 로 컬럼 타입을 추론한다.
pythondef _infer_types(self, values, na_values, no_dtype_specified, try_num_bool=True): # ... 결측 마스킹 생략 # 1단계: 숫자 변환을 먼저 시도한다 if try_num_bool and is_object_dtype(values.dtype): try: result, result_mask = lib.maybe_convert_numeric(values, na_values, False, ...) except (ValueError, TypeError): # 숫자로 못 바꾸면 원본(object)을 그대로 둔다 na_count = parsers.sanitize_objects(values, na_values) result = values else: # ... dtype_backend 지정 시 IntegerArray/BooleanArray로 감싸는 코드 생략 na_count = isna(result).sum() else: result = values # ... 생략 # 2단계: 1단계에서 object로 남았을 때만 불리언 변환을 시도한다 if ( result.dtype == np.object_ and try_num_bool and (len(result) == 0 or not isinstance(result[0], int)) ): result, bool_mask = libops.maybe_convert_bool( np.asarray(values), true_values=self.true_values, false_values=self.false_values, ... ) # ... 생략 return result, na_count
여기서 차이가 생긴다. csv때와 달리, openpyxl을 거치고 온 True/False 데이터는 string이 아닌 bool이다. 그리고 파이썬에서의 bool 타입은 int 타입을 상속받은 타입이다.
python# bool 값들이 int의 instance가 될 수 있다는 것을 알 수 있는 코드 print(isinstance(True, int)) # True print(int(False)) # 0
따라서 이는 1단계에서 숫자 변환이 가능하다는 것을 의미한다. 따라서 각각 True / False 는 각각 1.0 / 0.0으로 변환이 된다. 그리고 이미 float 타입으로 변경이 되었기 때문에 2단계에서 boolean 타입으로 변경될 수 없다.
NaN 또한 float형이기 때문에 전체 컬럼이 float으로 지정될 수 있게 된 것이다. 형 변환에 따라 데이터도 float로 변환되었다.
csv와 excel의 동작 방식 비교(FlowChart)
렌더링 중...
렌더링 중...
pandas가 컬럼 타입을 바꾸는 것은 문제일까?
형 변환이 일어나도 당장은 문제가 드러나지 않는다. 그 대신 오히려 발견이 늦어지기도 한다.
boolean 타입은 기본적으로 sum() , mean() 등의 집계 함수도 같이 쓸 수 있기 때문에 1.0 / 0.0으로 변환된다고 해도 큰 문제가 없는 것처럼 보일 수 있다. 조건문에서 또한 각 값이 truthy / falsy한 값으로 작동하기 때문에 동작 자체는 문제가 없다. 이러면 데이터가 몰래(?) 변경이 되었음에도 변경되었다는 사실을 발견할 수 없다.
이럴 땐 추가적인 형 변환을 할 때 문제가 되기도 한다. 컬럼 형 변환은 astype 을 이용하여 변환을 하기도 한다.
pythondf_excel["bool_data"] = df_excel["bool_data"].astype(bool)
이렇게 변환하면 잘 될 것 같지만 다음과 같이 df가 바뀌어 있는 것을 확인할 수 있다.
NaN 으로 표기되어 있던 것이 True로 변경되어 있다. NaN 은 앞서 설명한 것처럼 numpy.float64 타입의 float 값이다. float은 0.0이 아닌 이상 truthy한 값이기 때문에 boolean으로 형 변환을 시도하면 True로 변환이 될 수밖에 없다. 이는 즉, 원래 우리가 확인했던 결측치의 보존을 보장할 수 없어서 데이터가 틀어지는 문제가 생긴다.
원본 그대로를 읽어야 하는 경우에는 명시적으로 데이터의 변경이 보이면 잘못되었음을 바로 인지할 수 있다. 최대한 데이터를 보존하는 방향으로 df를 생성해야 옳다.
해결 방법은?
excel 데이터를 df로 변환할 때 dtype이라는 keyword argument를 사용하여 컬럼 타입을 명시하면 된다. 컬럼을 명시하면 추론 결과보다 지정한 타입이 우선하기 때문이다.
pythondf = pd.read_excel( "./test.xlsx", dtype={"bool_data": "boolean"} )
(추가 내용) object로 변환하면 되는 것 아닌지 궁금할 수 있다.
pythondf = pd.read_excel( "./test.xlsx", dtype={"bool_data": object} # object로 변환하면 NA 대신 NaN이 나올텐데 말이다. )
- np의 데이터 형식은 파이썬의 포인터 array를 사용하는 대신 타입을 명시하고 연속된 저장공간을 사용한다. 이를 쓰는 이유는 더 빠르게 데이터를 처리하기 위함인데 object로 변환한다는 건 포인터 array를 사용하는 것이기 때문에 속도의 이점을 포기하는 선택이다.
NaN은 원래 결측 표현을 위해 만들어진 값이 아니다. 엄밀하게 얘기하자면 결측치를 표현하는<NA>로 변경하는 것이 옳다.
마치며
이 문제는 pandas가 numpy를 이용하여 만들어져 생긴 동작의 제약으로 인한 것이었다. numpy의 bool이나 int 타입에는 결측을 담을 수 없게 설계되었고 결측을 나타내는 NaN이 float 값이기 때문에, 결측을 담으려면 컬럼 전체가 float이 될 수밖에 없었던 것이다.
이 부분에 있어서 코드가 고쳐져야 하지 않을까?라는 생각을 한 것에 이어 이미 논의가 활발하게 되고 있다는 것을 확인하였다. (논의 되고 있는 깃허브 이슈) 그리고 해결 방법에서 사용한 boolean 타입이 그 결과 중 하나다. numpy가 아닌 pandas가 직접 정의한 타입이기 때문에 결측을 <NA>로 담을 수 있다. 다만 기본값은 여전히 numpy 타입이고, 이를 어떻게 바꿀지가 아직 논의되고 있는 상태로 보인다.
이번에 직접 패키지를 확인하면서 이런 유명 패키지는 다른 패키지에 의존성이 강하게 결합되어 있는 경우에 어떤 판단을 하며 보수해나가는지 잠깐이나마 엿볼 수 있었고, 그 점이 흥미로웠던 시간이었다.
References
- pandas : https://github.com/pandas-dev/pandas
- openpyxl : https://foss.heptapod.net/openpyxl/openpyxl
- pandas 결측 처리 문서 : https://pandas.pydata.org/docs/user_guide/missing_data.html
read_excelAPI : https://pandas.pydata.org/docs/reference/api/pandas.read_excel.html