Pandas와 Numpy의 숫자형 (feat. apply 잘 쓰기)

송희
6 min readFeb 11, 2023

--

(이번 프로젝트를 끝낸 나의 모습.. )

Photo by shiyang xu on Unsplash

이번 프로젝트에서 제일 고생한 부분은 아니었지만, 그래도 그 다음으로 애를 먹었던 부분인 Pandas의 숫자형과 관련된 부분이었다. 로직을 설계하고 코드를 쓰는 중이었는데, 예상과 다른 기댓값에 골치를 앓았던 부분이기도 하다. 이유를 찾는 과정에서 꽤나 흥미로움을 느껴서 글로 남겨보려고 한다.

Set up

  • 각 DataFrame에서 apply(lambda row: row['row1'], axis=1)라는 각 row1의 값을 보여주는 함수를 적용한다고 생각해보자. (참고 : apply는 DataFrame에 함수를 적용하여 반환하는 메서드이다)
r1 = [1,2,3,4]
r2 = [5,6,7,8]
r3 = [9,10,11,12]
df1 = pd.DataFrame({'row1':r1,'row2':r2,'row3':r3})
r1 = [1,np.nan,2,3]
r2 = [4,5,np.nan,6]
r3 = [7,8,9,np.nan]

df2 = pd.DataFrame({'row1':r1,'row2':r2,'row3':r3})
  • 예상값으로는 df1에서 1,2,3,4 그리고 df2에서 1, NaN, 2, 3의 series 값을 기대할 것이다.
  • 하지만 실제 결과값은 df2에서 dtype이 float64인 1.0, NaN, 2.0, 3.0의 series 형태이다.

분명히 int형을 넣었는데 왜 float형이 나올까?

이에 대한 해답을 찾기 위해 여러 글을 찾아봤다.

결론부터 말하자면,

  • NaN값 때문이다.
  • apply함수는 해당하는 axis값에 따라 적정한 data type을 upcast/downcast하여 선택한다.
  • 일반적으로 object type, 숫자형은 float type을 선택한다.
  • 가능하면 원본의 type을 지키려고 하지만, int type에서는 NaN값을 제공하지 않는다.
  • 따라서 NaN값을 제공하는 float type으로 타입이 바뀌게 되는 것이다.

Solution

pandas 공식문서를 참고하면, 최종 반환값의 형태를 result_type 에 따라 결정할 수 있다고 되어 있다.

pandas.DataFrame.apply

DataFrame.apply(func, axis=0, raw=False, result_type=None, args=(), **kwargs)[source]

Apply a function along an axis of the DataFrame.

Objects passed to the function are Series objects whose index is either the DataFrame’s index (axis=0) or the DataFrame’s columns (axis=1). By default (result_type=None), the final return type is inferred from the return type of the applied function. Otherwise, it depends on the result_type argument.

그래서 result_type 을 이용하면 해결 할 수 있을줄 알았으나, 반환 값은 타입이 아니라 expand / reduce / broadcast 의 세가지 형태만 가능했다..

따라서 type casting을 통해 NaN을 지원하는 숫자형 Int64(대문자 I)으로 바꿔줬다.

다만, 프로젝트에서는 예외처리를 통해 NaN값의 데이터를 지우는 방식으로 해결하였다.

Advanced

  • 문제를 발견하고, 그 후에 읽던 책에서도 해당사항을 찾을 수 있었다.

「 고성능 파이썬(2판 」, 미샤 고렐릭 과 이안오스발트, 2021

p.186–187

자신의 확장 데이터 타입을 섞어서 사용한다. 예를 들어 Numpy에서 온 타입은 int(1 바이트), int64(8바이트, 첫 글자가 소문자 i), float16(2바이트), float64(8바이트), bool(1바이트) 등이 있다. 팬더스가 제공하는 추가 타입으로 categorical과 datetimez가 있다. 외부에서는 이 타입들이 비슷하게 작동하는 것처럼 보이지만, 내부적으로 pandas 코드 기반에 타입별 pandas code와 코드 중복이 있다.

pandas는 numpy 데이터 타입을 사용했지만, 발전하는 과정에서 3가 논리의 ‘없는 데이터(NaN)’ 동작을 인식하는 pandas 자체 데이터 타입이 늘어났다. 그래서 numpy의 int64(NaN을 인식하지 못한다)와 pandass Int64(내부적으로 정수와 NaN을 표현하는 Bit 마스크로 이뤄진 2열 데이터를 사용한다)를 구분해야한다. 참고로 Numpy의 float64는 NaN을 인식한다.

Numpy의 데이터 타입을 사용해서 생긴 부작용이 있다. float는 NaN(없는 값) 상태가 있지만, int나 bool 객체는 그렇지 않다. 그래서 pandas에서 int나 bool 수열에 NaN값을 도입하면 이 수열이 float 타입으로 바뀐다. int를 float으로 변환하면 같은 Bit로 표현할 수 있는 수의 정밀도가 줄어들고, 가장 작은 float가 float16이라서 bool을 float으로 변환하면 데이터 크기가 2배 커진다.

널이 될 수 있는 Int64(대문자 ‘I’)가 버전 0.24에서 Pandas 확장 타입으로 도입됐다. 내부적으로는 Int64는 값을 표현하려고 Numpy의 int64를, NaN 마스크로 bool을 사용한다. Int32, Int8도 마찬가지 방법을 쓴다. pandas 버전 1.0부터는 이와 동등한 널이 될 수 있는 불리언 타입도 있다.(NaN을 인식하지 못하는 numpy 불리언 타입은 bool이고 널이 될 수 있는 pandas dtype은 boolean이다.)

알면 알수록 신기한 타입의 세계. 코드를 쓰는 것도 중요하지만 결국 데이터 타입과 자료구조에 대한 이해가 필요함을 다시한번 느낀 순간이었다.

--

--

송희

커피와 책, 구름을 좋아하는 개발자입니다. 고민의 과정을 담고자 노력하고 있습니다. Github : https://github.com/song-hee-1