누락된 데이터 처리

튜토리얼에서 흔히 접하는 예제 데이터와 실제 데이터의 가장 큰 차이점은, 실제 데이터는 깨끗하거나 균일한 경우가 거의 없다는 점입니다. 특히 많은 흥미로운 데이터셋에는 일정량의 데이터가 누락되어 있기 마련입니다. 게다가 데이터 소스마다 누락된 데이터를 표현하는 방식이 제각각이라는 점은 문제를 더 복잡하게 만듭니다.

이번 장에서는 누락된 데이터 처리에 관한 일반적인 고려 사항을 논의하고, Pandas가 이를 어떻게 표현하는지 살펴보며, Python에서 누락된 데이터를 처리하기 위한 Pandas의 내장 도구들을 알아보겠습니다. 이 책 전반에서는 누락된 데이터를 보통 null, NaN, 또는 NA 값으로 지칭하겠습니다.

누락된 데이터 처리 방식의 절충안

표(Table)나 DataFrame에서 누락된 데이터의 존재를 추적하기 위해 다양한 접근 방식이 개발되었습니다. 일반적으로 누락된 값을 전체적으로 나타내는 마스크(Mask)를 사용하거나, 누락된 항목을 나타내는 센티넬(Sentinel) 값을 선택하는 두 가지 전략 중 하나를 사용합니다.

마스킹 방식에서는 마스크가 완전히 별도의 부울(Boolean) 배열일 수도 있고, 값의 null 상태를 로컬로 나타내기 위해 데이터 표현에서 1비트를 할당할 수도 있습니다.

센티넬 방식에서는 센티넬 값이 –9999나 흔치 않은 비트 패턴으로 누락된 정수를 나타내는 등 데이터별 규칙을 따를 수도 있고, IEEE 부동 소수점 사양의 일부인 특수 값 ‘NaN’(Not a Number)으로 누락된 부동 소수점 값을 나타내는 등 좀 더 범용적인 규칙을 따를 수도 있습니다.

두 방식 모두 장단점이 있습니다. 별도의 마스크 배열을 사용하려면 추가적인 부울 배열을 할당해야 하므로 저장 공간과 계산 모두에 오버헤드가 발생합니다. 센티넬 값 방식은 표현할 수 있는 유효 값의 범위를 줄이며, ‘NaN’ 같은 특수 값을 모든 데이터 타입에 사용할 수 없으므로 CPU나 GPU 연산 시 별도의(흔히 최적화되지 않은) 로직이 필요합니다.

대부분의 경우 보편적인 최적의 선택은 없으므로, 언어와 시스템마다 서로 다른 규칙을 사용합니다. 예를 들어 R 언어는 각 데이터 타입 내의 예약된 비트 패턴을 센티넬 값으로 사용하는 반면, SciDB 시스템은 NA 상태를 나타내기 위해 모든 셀에 추가 바이트를 할당합니다.

Pandas의 누락된 데이터 처리

Pandas가 누락된 값을 처리하는 방식은 부동 소수점이 아닌 데이터 타입에 대해 NA 값의 내장 개념이 없는 NumPy 패키지에 의존하기 때문에 다소 제약이 있습니다.

Pandas도 null을 나타내기 위해 각 데이터 타입별로 특정 비트 패턴을 지정하는 R의 방식을 따를 수도 있었겠지만, 이 접근 방식은 다소 비효율적인 것으로 드러났습니다. R에는 주요 데이터 타입이 4개뿐이지만, NumPy는 이보다 훨씬 많은 타입을 지원합니다. 예를 들어 R에는 단일 정수 타입만 있는 반면, NumPy는 비트 폭이나 엔디안 등을 고려할 때 14개의 기본 정수 타입을 지원합니다. 모든 NumPy 타입에 특정 비트 패턴을 예약하는 것은 타입별 특수 연산에서 과도한 오버헤드를 발생시키며, 심지어 NumPy 패키지를 새로 포크(Fork)해야 할 수도 있습니다. 또한 8비트 정수처럼 작은 데이터 타입에서는 마스크로 비트를 할당하면 표현 가능한 값의 범위가 크게 줄어듭니다.

이러한 제약과 장단점 때문에 Pandas에는 null 값을 저장하고 조작하는 두 가지 “모드”가 있습니다.

  • 기본 모드는 데이터 타입에 따라 센티넬 값으로 ‘NaN’ 또는 ’None’을 사용하는 방식입니다.
  • 또는 Pandas가 제공하는 nullable 데이터 타입(dtypes)을 선택적으로 사용합니다(이 장의 뒷부분에서 설명). 이 경우 누락된 항목을 추적하기 위한 별도의 마스크 배열이 생성되며, 사용자에게는 특수 값인 pd.NA로 표시됩니다.

어떤 경우든 Pandas API가 제공하는 연산과 조작 도구들은 누락된 항목을 예측 가능한 방식으로 처리하고 전파합니다. 왜 이런 방식을 선택했는지 이해하기 위해 ‘None’, ‘NaN’, ‘NA’ 각각의 장단점을 빠르게 살펴보겠습니다. 평소처럼 NumPy와 Pandas를 임포트하며 시작합니다.

import numpy as np
import pandas as pd

‘None’ 센티넬 값

일부 데이터 타입에서 Pandas는 ’None’을 센티넬 값으로 사용합니다. None은 Python 객체이므로, None을 포함하는 배열은 반드시 dtype=object를 가져야 합니다. 즉, Python 객체들의 시퀀스여야 합니다.

예를 들어 NumPy 배열에 ’None’을 전달했을 때 어떤 일이 일어나는지 확인해 보세요.

vals1 = np.array([1, None, 2, 3])
vals1
array([1, None, 2, 3], dtype=object)

dtype=object는 NumPy가 배열 내용으로부터 추론할 수 있는 가장 공통적인 타입 표현이 Python 객체임을 의미합니다. 이렇게 None을 사용할 때의 단점은 데이터 연산이 Python 수준에서 수행되므로, 네이티브 타입 배열에서 기대할 수 있는 빠른 연산 속도보다 훨씬 큰 오버헤드가 발생한다는 점입니다.

%timeit np.arange(1E6, dtype=int).sum()
2.73 ms ± 288 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)
%timeit np.arange(1E6, dtype=object).sum()
92.1 ms ± 3.42 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)

또한 Python은 None을 사용한 산술 연산을 지원하지 않으므로, sum이나 min 같은 집계 연산 시 대개 오류가 발생합니다.

vals1.sum()
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
/var/folders/xc/sptt9bk14s34rgxt7453p03r0000gp/T/ipykernel_91333/1181914653.py in <module>
----> 1 vals1.sum()

~/.local/share/virtualenvs/python-data-science-handbook-2e-u_kwqDTB/lib/python3.9/site-packages/numpy/core/_methods.py in _sum(a, axis, dtype, out, keepdims, initial, where)
     46 def _sum(a, axis=None, dtype=None, out=None, keepdims=False,
     47          initial=_NoValue, where=True):
---> 48     return umr_sum(a, axis, dtype, out, keepdims, initial, where)
     49 
     50 def _prod(a, axis=None, dtype=None, out=None, keepdims=False,

TypeError: unsupported operand type(s) for +: 'int' and 'NoneType'

이러한 이유로 Pandas는 숫자 배열에서 ’None’을 센티넬로 사용하지 않습니다.

NaN: 누락된 숫자 데이터

또 다른 센티넬인 ’NaN’은 조금 다릅니다. 이는 표준 IEEE 부동 소수점 표현을 사용하는 모든 시스템에서 인식하는 특수한 부동 소수점 값입니다.

vals2 = np.array([1, np.nan, 3, 4])
vals2
array([ 1., nan,  3.,  4.])

NumPy는 이 배열의 타입을 부동 소수점형으로 선택했습니다. 이는 이전의 객체(object) 배열과 달리, 컴파일된 코드 수준에서 지원하는 빠른 연산이 가능하다는 것을 의미합니다. 다만 NaN은 마치 바이러스처럼 접촉하는 모든 데이터를 오염시킨다는 점에 유의해야 합니다. 어떤 연산을 하든 NaN이 포함된 산술 결과는 다시 NaN이 됩니다.

1 + np.nan
nan
0 * np.nan
nan

따라서 이러한 값들에 대한 집계 연산은 (에러는 나지 않더라도) 항상 유용한 결과를 내지는 못합니다.

vals2.sum(), vals2.min(), vals2.max()
(nan, nan, nan)

대신 NumPy는 이러한 누락된 값을 무시하고 계산하는 전용 집계 함수들을 제공합니다.

np.nansum(vals2), np.nanmin(vals2), np.nanmax(vals2)
(8.0, 1.0, 4.0)

NaN의 가장 큰 한계는 부동 소수점 전용이라는 점입니다. 정수나 문자열 등 다른 타입에는 그에 상응하는 NaN 값이 존재하지 않습니다.

Pandas에서의 NaN 및 None

’NaN’과 ’None’은 각각의 용도가 있으며, Pandas는 이 둘을 거의 호환해서 처리하고 필요에 따라 적절히 변환하도록 설계되었습니다.

pd.Series([1, np.nan, 2, None])
0    1.0
1    NaN
2    2.0
3    NaN
dtype: float64

적절한 센티넬 값이 없는 타입의 경우, NA 값이 나타나면 Pandas가 자동으로 타입을 변환합니다. 예를 들어 정수 배열의 값을 np.nan으로 설정하면, NA를 수용하기 위해 배열이 자동으로 부동 소수점 타입으로 업캐스트됩니다.

x = pd.Series(range(2), dtype=int)
x
0    0
1    1
dtype: int64
x[0] = None
x
0    NaN
1    1.0
dtype: float64

정수 배열을 부동 소수점으로 캐스팅하는 것 외에도, Pandas는 자동으로 NoneNaN 값으로 변환합니다.

이러한 방식은 R 같은 통계 전용 언어의 좀 더 통합된 접근 방식에 비하면 다소 임시방편처럼 느껴질 수 있습니다. 하지만 Pandas의 센티넬/캐스팅 방식은 실무에서 매우 효과적으로 작동하며 문제를 일으키는 경우도 거의 없습니다.

다음 표는 NA 값이 유입될 때 Pandas의 타입 변환(업캐스팅) 규칙을 정리한 것입니다.

타입 분류 NA 발생 시 변환 규칙 NA 센티넬 값
float 변화 없음 np.nan
object 변화 없음 None 또는 np.nan
integer float64로 변환 np.nan
boolean object로 변환 None 또는 np.nan

Pandas에서 문자열 데이터는 항상 object 타입으로 저장된다는 점을 유의하세요.

Pandas Nullable 데이터 타입

Pandas 초기 버전에서는 ‘NaN’과 ’None’이 유일한 누락 데이터 표현이었습니다. 이로 인해 암시적인 타입 변환 문제가 발생하곤 했는데, 예를 들어 데이터가 누락된 ’정수’ 배열을 온전히 표현할 방법이 없었습니다.

이를 해결하기 위해 Pandas는 나중에 일반 타입과 구분되도록 이름을 대문자로 시작하는 nullable dtypes를 추가했습니다(예: np.int32 대신 pd.Int32). 하위 호환성을 위해 이러한 타입들은 명시적으로 요청한 경우에만 사용됩니다.

예를 들어 다음은 세 가지 종류의 누락 데이터 마커를 모두 포함하는 리스트로부터 생성된 정수형 ’Series’입니다.

pd.Series([1, np.nan, 2, None, pd.NA], dtype="Int32")
0       1
1    <NA>
2       2
3    <NA>
4    <NA>
dtype: Int32

이 표현 방식은 이번 장에서 다룰 다른 모든 연산에서도 기존 방식들과 호환되어 사용될 수 있습니다.

Null 값 다루기

앞서 보았듯이 Pandas는 ‘None’, ‘NaN’, ’NA’를 누락된 값(null)으로 보고 거의 동일하게 처리합니다. 이러한 규칙을 쉽게 적용할 수 있도록 Pandas는 데이터 구조 내에서 null 값을 감지, 제거, 교체하는 여러 메서드를 제공합니다.

  • isnull: 누락된 값을 표시하는 부울 마스크를 생성합니다.
  • notnull: isnull의 반대 연산을 수행합니다.
  • dropna: 데이터에서 null 값을 제외한 버전을 반환합니다.
  • fillna: 누락된 값을 특정 값으로 채우거나 교체한 복사본을 반환합니다.

이 기능들을 간단히 살펴보며 이번 장을 마무리하겠습니다.

Null 값 감지

Pandas 객체에서 null 데이터를 감지하는 데 유용한 두 메서드는 isnull()notnull()입니다. 둘 다 데이터에 대한 부울 마스크를 반환합니다. 예를 들어 다음과 같습니다.

data = pd.Series([1, np.nan, "hello", None])
data.isnull()
0    False
1     True
2    False
3     True
dtype: bool

데이터 인덱싱 및 선택에서 다뤘듯, 이러한 부울 마스크는 ’Series’나 ’DataFrame’의 인덱스로 직접 활용합니다.

data[data.notnull()]
0        1
2    hello
dtype: object

isnull()notnull() 메서드는 DataFrame 객체에서도 동일한 방식으로 부울 결과를 생성합니다.

Null 값 제거

마스킹 외에도 NA 값을 직접 제거하는 dropna()와 채워 넣는 fillna()라는 편의 메서드가 있습니다. ’Series’의 경우 결과는 매우 직관적입니다.

data.dropna()
0        1
2    hello
dtype: object

DataFrame에서는 고려할 옵션이 좀 더 많습니다. 다음 DataFrame을 예로 들어보겠습니다.

df = pd.DataFrame([[1, np.nan, 2], [2, 3, 5], [np.nan, 4, 6]])
df
0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6

DataFrame에서는 개별 값 하나만 삭제할 수는 없으며, 행 전체나 열 전체를 삭제해야 합니다. 분석 목적에 따라 선택이 달라질 수 있으므로 dropna()는 여러 옵션을 제공합니다.

dropna()는 null 값이 하나라도 포함된 모든 행을 삭제합니다.

df.dropna()
0 1 2
1 2.0 3.0 5

축(Axis)을 변경하여 삭제할 수도 있습니다. axis=1 또는 axis='columns'를 사용하면 null 값을 포함하는 모든 열을 삭제합니다.

df.dropna(axis="columns")
2
0 2
1 5
2 6

하지만 이 방식은 유효한 데이터까지 너무 많이 삭제해버릴 수 있습니다. 그럴 때는 null 값이 전부인 경우나, 혹은 일정 개수 이상인 경우에만 삭제하도록 설정합니다. howthresh 매개변수가 이 정밀한 제어를 돕습니다.

기본값은 how='any'이며, null이 하나라도 있으면 행/열을 삭제합니다. how='all'로 지정하면 해당 행/열의 모든 값이 null인 경우에만 삭제합니다.

df[3] = np.nan
df
0 1 2 3
0 1.0 NaN 2 NaN
1 2.0 3.0 5 NaN
2 NaN 4.0 6 NaN
df.dropna(axis="columns", how="all")
0 1 2
0 1.0 NaN 2
1 2.0 3.0 5
2 NaN 4.0 6

더 세밀한 제어가 필요하다면 thresh 매개변수를 사용해 null이 아닌 값이 최소 몇 개 이상 있어야 유지할지를 지정합니다.

df.dropna(axis="rows", thresh=3)
0 1 2 3
1 2.0 3.0 5 NaN

위 예시에서는 첫 번째와 마지막 행이 null이 아닌 값을 두 개씩만 가지고 있어 삭제되었습니다.

Null 값 채우기

상황에 따라서는 NA 값을 삭제하기보다 유효한 값으로 대체하는 것이 더 나을 수 있습니다. 0과 같은 단일 수치로 채울 수도 있고, 주변의 정상적인 값들을 이용해 보간(Interpolation)하거나 추정할 수도 있습니다. isnull() 메서드를 마스크로 활용해 수동으로 바꿀 수도 있지만, Pandas는 이를 훨씬 쉽게 처리할 수 있는 fillna() 메서드를 제공합니다. 이 메서드는 null 값이 특정 값으로 대체된 배열의 복사본을 반환합니다.

다음 ’Series’를 예로 들어보겠습니다.

data = pd.Series([1, np.nan, 2, None, 3], index=list("abcde"), dtype="Int32")
data
a       1
b    <NA>
c       2
d    <NA>
e       3
dtype: Int32

NA 항목들을 0 같은 특정 값으로 한꺼번에 채울 수 있습니다.

data.fillna(0)
a    1
b    0
c    2
d    0
e    3
dtype: Int32

이전 값을 다음으로 전달해 채우는 ‘정방향 채우기(Forward Fill)’ 방식을 지정할 수도 있습니다.

# 정방향 채우기
data.fillna(method="ffill")
a    1
b    1
c    2
d    2
e    3
dtype: Int32

반대로 다음 값을 이전으로 끌어와 채우는 ‘역방향 채우기(Backward Fill)’ 방식도 가능합니다.

# 역방향 채우기
data.fillna(method="bfill")
a    1
b    2
c    2
d    3
e    3
dtype: Int32

DataFrame에서도 옵션은 비슷하지만, 채우기를 수행할 axis(축)를 추가로 지정합니다.

df
0 1 2 3
0 1.0 NaN 2 NaN
1 2.0 3.0 5 NaN
2 NaN 4.0 6 NaN
df.fillna(method="ffill", axis=1)
0 1 2 3
0 1.0 1.0 2.0 2.0
1 2.0 3.0 5.0 5.0
2 NaN 4.0 6.0 6.0

정방향 채우기를 할 때, 해당 항목 앞에 유효한 값이 없으면 NA 상태가 그대로 유지됩니다.