결측 데이터

결측 데이터 (Missing data)

이 섹션에서는 Polars에서 결측 데이터를 어떻게 다루는지 배워 볼게요. 누락된 값은 데이터 작업에서 피할 수 없는 친구라서, nullNaN의 차이부터 채우는 방법까지 차근차근 살펴볼게요.

출처: 공식문서

nullNaN

Polars에서 결측 데이터는 null 값으로 표현됩니다. 이 결측 값 null은 숫자 타입을 포함한 모든 데이터 타입에 사용돼요. Polars는 부동소수점을 담은 열에 대해 NaN("Not a Number") 값도 지원하는데, NaN은 유효한 부동소수점 값으로 간주되어 결측 데이터와는 달라요. NaN 값은 아래에서 따로 다룰게요.

시리즈나 데이터프레임을 만들 때, 값 하나를 null로 설정할 수 있습니다.

import polars as pl

df = pl.DataFrame(
    {
        "value": [1, None],
    },
)
print(df)
shape: (2, 1)
┌───────┐
│ value │
│ ---   │
│ i64   │
╞═══════╡
│ 1     │
│ null  │
└───────┘

pandas와의 차이: pandas에서는 결측 데이터를 나타내는 값이 열의 데이터 타입에 따라 달라요. Polars에서는 결측 데이터가 항상 null 값으로 표현됩니다.

결측 데이터 메타데이터 (Missing data metadata)

Polars는 각 시리즈의 결측 데이터에 관한 몇 가지 메타데이터를 추적해요. 이 메타데이터 덕분에 Polars는 결측 값에 대한 몇 가지 기본적인 질문, 즉 "몇 개가 빠져 있나"와 "어느 것이 빠져 있나"를 매우 효율적으로 답할 수 있습니다.

열에서 빠진 값이 몇 개인지 알아내려면 함수 null_count를 써요.

null_count_df = df.null_count()
print(null_count_df)
shape: (1, 1)
┌───────┐
│ value │
│ ---   │
│ u32   │
╞═══════╡
│ 1     │
└───────┘

null_count는 데이터프레임, 데이터프레임의 열, 또는 시리즈에 직접 호출할 수 있어요. null_count는 결과가 이미 알려져 있기 때문에 저렴한 연산입니다.

Polars는 시리즈에서 어떤 값이 빠져 있는지 알기 위해 "유효성 비트맵(validity bitmap)"이라는 것을 사용해요. 유효성 비트맵은 비트로 인코딩되어 있어 메모리 효율적입니다. 시리즈 길이가 $n$이면 유효성 비트맵의 비용은 $n / 8$ 바이트예요. 함수 is_null은 유효성 비트맵을 사용해 어떤 값이 null이고 어떤 값이 아닌지 효율적으로 알려 줍니다.

is_null_series = df.select(
    pl.col("value").is_null(),
)
print(is_null_series)
shape: (2, 1)
┌───────┐
│ value │
│ ---   │
│ bool  │
╞═══════╡
│ false │
│ true  │
└───────┘

is_null은 데이터프레임의 열이나 시리즈에 직접 쓸 수 있어요. 이것 역시 결과가 Polars에 이미 알려져 있기 때문에 저렴한 연산입니다.

Polars가 왜 유효성 비트맵에 메모리를 쓰는가?: 전부 트레이드오프 덕분이에요. 열마다 메모리를 조금 더 씀으로써 Polars는 대부분의 연산을 훨씬 더 효율적으로 수행할 수 있어요. 유효성 비트맵이 없다면 무언가를 계산할 때마다 시리즈의 각 위치에 유효한 값이 있는지 일일이 확인해야 합니다. 유효성 비트맵이 있으면 Polars는 연산을 적용할 수 있는 위치를 자동으로 알고 있어요.

결측 데이터 채우기 (Filling missing data)

시리즈의 결측 데이터는 함수 fill_null로 채울 수 있어요. 결측 데이터를 실제로 어떻게 채울지는 몇 가지 다른 방식으로 지정할 수 있습니다.

  • 올바른 데이터 타입의 리터럴;
  • 다른 열에서 계산한 값으로 대체하는 것 같은 Polars 표현식;
  • 앞으로 또는 뒤로 채우기 같은 이웃 값에 기반한 전략; 그리고
  • 보간(interpolation).

각 방법이 어떻게 동작하는지 보여 주기 위해, 두 번째 열에 결측 값 두 개가 있는 간단한 데이터프레임을 정의해 볼게요.

df = pl.DataFrame(
    {
        "col1": [0.5, 1, 1.5, 2, 2.5],
        "col2": [1, None, 3, None, 5],
    },
)
print(df)
shape: (5, 2)
┌──────┬──────┐
│ col1 ┆ col2 │
│ ---  ┆ ---  │
│ f64  ┆ i64  │
╞══════╪══════╡
│ 0.5  ┆ 1    │
│ 1.0  ┆ null │
│ 1.5  ┆ 3    │
│ 2.0  ┆ null │
│ 2.5  ┆ 5    │
└──────┴──────┘

지정된 리터럴 값으로 채우기 (Fill with a specified literal value)

결측 데이터를 지정된 리터럴 값으로 채울 수 있어요. 이 리터럴 값은 null 값이 나타나는 모든 곳을 대체합니다.

fill_literal_df = df.with_columns(
    pl.col("col2").fill_null(3),
)
print(fill_literal_df)
shape: (5, 2)
┌──────┬──────┐
│ col1 ┆ col2 │
│ ---  ┆ ---  │
│ f64  ┆ i64  │
╞══════╪══════╡
│ 0.5  ┆ 1    │
│ 1.0  ┆ 3    │
│ 1.5  ┆ 3    │
│ 2.0  ┆ 3    │
│ 2.5  ┆ 5    │
└──────┴──────┘

하지만 이는 사실 일반적인 경우, 즉 함수 fill_null이 Polars 표현식의 결과에서 해당하는 값으로 결측 값을 대체하는 경우의 특수한 사례일 뿐이에요. 이어서 살펴보죠.

표현식으로 채우기 (Fill with an expression)

일반적인 경우 결측 데이터는 일반 Polars 표현식 결과에서 해당하는 값을 뽑아 채울 수 있어요. 예를 들어 첫 번째 열의 두 배에서 값을 가져와 두 번째 열을 채울 수 있습니다.

fill_expression_df = df.with_columns(
    pl.col("col2").fill_null((2 * pl.col("col1")).cast(pl.Int64)),
)
print(fill_expression_df)
shape: (5, 2)
┌──────┬──────┐
│ col1 ┆ col2 │
│ ---  ┆ ---  │
│ f64  ┆ i64  │
╞══════╪══════╡
│ 0.5  ┆ 1    │
│ 1.0  ┆ 2    │
│ 1.5  ┆ 3    │
│ 2.0  ┆ 4    │
│ 2.5  ┆ 5    │
└──────┴──────┘

이웃 값에 기반한 전략으로 채우기 (Fill with a strategy based on neighbouring values)

결측 데이터를 이웃 값에 기반한 채우기 전략을 따라 채울 수도 있어요. 두 가지 더 간단한 전략은 채워질 null 값 바로 앞이나 바로 뒤에 오는 첫 번째 비-null 값을 찾는 방식입니다.

fill_forward_df = df.with_columns(
    pl.col("col2").fill_null(strategy="forward").alias("forward"),
    pl.col("col2").fill_null(strategy="backward").alias("backward"),
)
print(fill_forward_df)
shape: (5, 4)
┌──────┬──────┬─────────┬──────────┐
│ col1 ┆ col2 ┆ forward ┆ backward │
│ ---  ┆ ---  ┆ ---     ┆ ---      │
│ f64  ┆ i64  ┆ i64     ┆ i64      │
╞══════╪══════╪═════════╪══════════╡
│ 0.5  ┆ 1    ┆ 1       ┆ 1        │
│ 1.0  ┆ null ┆ 1       ┆ 3        │
│ 1.5  ┆ 3    ┆ 3       ┆ 3        │
│ 2.0  ┆ null ┆ 3       ┆ 5        │
│ 2.5  ┆ 5    ┆ 5       ┆ 5        │
└──────┴──────┴─────────┴──────────┘

다른 채우기 전략은 API 문서에서 찾아볼 수 있어요.

보간으로 채우기 (Fill with interpolation)

추가로, 중간의 결측 데이터는 함수 fill_null 대신 함수 interpolate를 써서 보간할 수 있어요.

fill_interpolation_df = df.with_columns(
    pl.col("col2").interpolate(),
)
print(fill_interpolation_df)
shape: (5, 2)
┌──────┬──────┐
│ col1 ┆ col2 │
│ ---  ┆ ---  │
│ f64  ┆ f64  │
╞══════╪══════╡
│ 0.5  ┆ 1.0  │
│ 1.0  ┆ 2.0  │
│ 1.5  ┆ 3.0  │
│ 2.0  ┆ 4.0  │
│ 2.5  ┆ 5.0  │
└──────┴──────┘

참고: interpolate로는 시리즈의 맨 앞과 맨 끝에 있는 null은 그대로 남습니다.

NaN (Not a Number) 값

시리즈의 결측 데이터는 시리즈의 데이터 타입과 무관하게 항상 null 값으로만 표현돼요. 부동소수점 데이터 타입의 열에는 null과 혼동될 수 있는 NaN 값이 있을 수 있습니다.

특수 값 NaN은 직접 만들 수 있어요.

import numpy as np

nan_df = pl.DataFrame(
    {
        "value": [1.0, np.nan, float("nan"), 3.0],
    },
)
print(nan_df)
shape: (4, 1)
┌───────┐
│ value │
│ ---   │
│ f64   │
╞═══════╡
│ 1.0   │
│ NaN   │
│ NaN   │
│ 3.0   │
└───────┘

또한 NaN은 계산의 결과로 생길 수도 있어요.

df = pl.DataFrame(
    {
        "dividend": [1, 0, -1],
        "divisor": [1, 0, -1],
    }
)
result = df.select(pl.col("dividend") / pl.col("divisor"))
print(result)
shape: (3, 1)
┌──────────┐
│ dividend │
│ ---      │
│ f64      │
╞══════════╡
│ 1.0      │
│ NaN      │
│ 1.0      │
└──────────┘

참고: 기본적으로 pandas에서는 정수 열의 NaN 값이 열을 부동소수점 타입으로 캐스팅하게 만듭니다. Polars에서는 그렇게 되지 않고 대신 예외가 발생해요.

NaN 값은 부동소수점 데이터의 한 종류로 간주되며 Polars에서 결측 데이터로 간주되지 않습니다. 이 말은 곧:

  • NaN 값은 함수 null_count로 세지 않고; 그리고
  • NaN 값은 특화된 함수 fill_nan 메서드로는 채워지지만 함수 fill_null로는 채워지지 않습니다.

Polars에는 is_nanfill_nan 함수가 있는데, 이들은 is_nullfill_null과 비슷하게 동작해요. 결측 데이터와 달리 Polars는 NaN 값에 관한 메타데이터를 보관하지 않으므로, is_nan은 실제 계산을 수반합니다.

nullNaN 값의 또 한 가지 차이는, meansum 같은 숫자 집계 함수는 결과를 계산할 때 결측 값을 건너뛰는 반면 NaN 값은 계산에 고려되어 보통 결과에 퍼져 나간다는 것이에요. 원한다면 NaN 값의 등장을 null 값으로 대체해서 이 동작을 피할 수 있습니다.

mean_nan_df = nan_df.with_columns(
    pl.col("value").fill_nan(None).alias("replaced"),
).select(
    pl.all().mean().name.suffix("_mean"),
    pl.all().sum().name.suffix("_sum"),
)
print(mean_nan_df)
shape: (1, 4)
┌────────────┬───────────────┬───────────┬──────────────┐
│ value_mean ┆ replaced_mean ┆ value_sum ┆ replaced_sum │
│ ---        ┆ ---           ┆ ---       ┆ ---          │
│ f64        ┆ f64           ┆ f64       ┆ f64          │
╞════════════╪═══════════════╪═══════════╪══════════════╡
│ NaN        ┆ 2.0           ┆ NaN       ┆ 4.0          │
└────────────┴───────────────┴───────────┴──────────────┘

NaN 값에 대해 더 배우고 싶다면 부동소수점 숫자 데이터 타입에 관한 섹션을 참고하세요.

더 알아보기 (Learn more)