[Pandas 데이터프레임 변환] NaN 값 포함 데이터프레임, 연속성 유지하며 효율적으로 재구성하는 방법

안녕하세요! 데이터 분석가와 개발자 여러분, Pandas를 사용하다 보면 결측값(NaN)을 포함한 데이터프레임을 특정 조건에 맞춰 재구성해야 하는 복잡한 상황에 직면하곤 합니다. 단순히 NaN 값을 제거하는 것을 넘어, 컬럼 내 연속성을 유지하면서 새로운 형태의 데이터프레임을 만들어야 할 때가 그렇죠. 이번 기술 블로그에서는 스택오버플로우에서 제기된 이와 유사한 문제 상황을 분석하고, Pandas의 기능을 활용하여 요구사항을 충족하는 효과적인 데이터프레임 변환 방법을 제시하고자 합니다. 또한, 변환된 값들이 원래 어떤 컬럼에서 왔는지 그 연관성까지 유지하는 방법을 함께 다룹니다.

1. 문제 발생 상황

사용자는 다음과 같은 형태의 Python Pandas 데이터프레임을 가지고 있습니다.

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'A': [0.10, 0.03, -0.06, np.nan],
    'B': [np.nan, 0.12, np.nan, np.nan],
    'C': [-0.05, np.nan, 0.03, 0.07],
    'D': [np.nan, np.nan, np.nan, 0.11]
})
print(df)

초기 데이터프레임은 다음과 같습니다:

      A     B     C     D
0  0.10   NaN -0.05   NaN
1  0.03  0.12   NaN   NaN
2 -0.06   NaN  0.03   NaN
3   NaN   NaN  0.07  0.11

사용자는 이 데이터프레임에서 NaN 값을 제거하고, 다음 두 가지 주요 조건을 만족시키며 새로운 데이터프레임으로 축소(재구성)하기를 원합니다.

  1. 각 행의 비-NaN 값 개수가 항상 일정하게 유지되어야 합니다 (예시에서는 2개).
  2. 각 원본 컬럼 내의 연속적인 값들의 순서가 보존되어야 합니다 (예: 컬럼 A의 (0.10, 0.03, -0.06), 컬럼 C의 (0.03, 0.07)).

또한, 가능하다면 새로운 데이터프레임의 각 값이 원래 어느 컬럼에서 왔는지 그 연관성을 유지하고 싶어 합니다 (예: -0.06은 A에서 왔고, 0.12는 B에서 왔음을 알 수 있도록).

사용자가 원하는 최종 결과는 다음과 같습니다:

      E     F
0  0.10 -0.05   
1  0.03  0.12
2 -0.06  0.03
3  0.11  0.07

이 문제는 단순한 결측값 처리 이상의 복잡한 데이터 재구조화 작업을 요구합니다.

2. 문제의 핵심 분석 및 요구사항

이 상황은 특정 오류가 발생한 것이 아니라, Pandas 데이터프레임의 복잡한 변환 요구사항에 해당합니다. 핵심적인 요구사항들을 분석해 보면 다음과 같습니다.

2.1. NaN 값 처리 및 데이터프레임 축소

원본 데이터프레임에서 NaN 값들을 단순히 제거하는 것을 넘어, 남아있는 유효한 값들을 새로운 데이터프레임 형태로 재배치해야 합니다. 즉, 기존의 행-열 구조를 해체하고 새로운 논리적 구조를 만드는 것입니다.

2.2. 컬럼 내 연속 값 유지

각 원본 컬럼(A, B, C, D) 내에서 NaN을 제외한 유효한 숫자 값들은 그들의 원본 순서를 그대로 유지해야 합니다. 예를 들어, 컬럼 A의 값 (0.10, 0.03, -0.06)은 이 순서대로 처리되어야 하며, C 컬럼의 (0.03, 0.07) 또한 마찬가지입니다. 이는 `df.stack()`과 같이 인덱스를 기준으로 값을 쌓는 방식만으로는 해결하기 어렵습니다.

2.3. 새로운 행당 비-NaN 값 개수 고정

최종 결과 데이터프레임의 각 행은 일정한 개수의 유효한 값(예시에서는 2개)으로 채워져야 합니다. 이는 추출된 모든 비-NaN 값들을 원하는 컬럼 개수로 나누어 새로운 행렬 구조를 만들어야 함을 의미합니다.

2.4. 원본 컬럼 연관성 유지

변환된 데이터가 어떤 원본 컬럼에서 왔는지에 대한 정보는 데이터의 출처를 파악하고 후속 분석을 수행하는 데 매우 중요합니다. 이 정보를 최종 결과와 함께 제공할 수 있어야 합니다.

이러한 복합적인 요구사항들은 일반적인 Pandas 함수만으로는 한 번에 해결하기 어려울 수 있으며, 여러 단계를 거쳐 데이터를 추출하고 재구성하는 전략이 필요합니다.

3. 해결 방법 및 코드 예시

이 문제에 대한 해결책은 다음과 같은 단계를 통해 구현할 수 있습니다.

  1. 각 원본 컬럼에서 NaN 값을 제거하고, 남은 유효한 값들과 해당 값의 원본 컬럼명을 함께 추출합니다. 이때 컬럼 내 값들의 순서를 유지하는 것이 중요합니다.
  2. 추출된 모든 값을 하나의 평탄화된(flattened) 리스트로 모읍니다.
  3. 이 평탄화된 리스트를 원하는 컬럼 개수(예: 2개)에 맞춰 NumPy의 reshape 기능을 사용하여 새로운 행렬 형태로 만듭니다.
  4. 재구성된 행렬로 새로운 Pandas 데이터프레임을 생성하고, 필요하다면 원본 컬럼 연관성 정보를 별도로 제공합니다.

3.1. 모든 비-NaN 값과 원본 컬럼 정보 추출

먼저, 원본 데이터프레임의 각 컬럼을 반복하면서 NaN 값을 제거하고, 유효한 값과 해당 값이 속한 원본 컬럼명을 딕셔너리 형태로 리스트에 저장합니다. 이 과정에서 각 컬럼 내 값의 순서가 자연스럽게 유지됩니다.

import pandas as pd
import numpy as np

df = pd.DataFrame({
    'A': [0.10, 0.03, -0.06, np.nan],
    'B': [np.nan, 0.12, np.nan, np.nan],
    'C': [-0.05, np.nan, 0.03, 0.07],
    'D': [np.nan, np.nan, np.nan, 0.11]
})

# 1. 모든 비-NaN 값과 그 원본 컬럼 정보를 추출
all_extracted_data = []
for col_name in df.columns:
    # dropna()를 사용하여 컬럼 내 NaN 값을 제거하고, 남아있는 값들의 순서 유지
    non_nan_values = df[col_name].dropna().tolist()
    for val in non_nan_values:
        all_extracted_data.append({'value': val, 'original_column': col_name})

print("--- 추출된 모든 비-NaN 데이터 (순서 유지) ---")
for item in all_extracted_data:
    print(item)

위 코드를 실행하면 다음과 같은 결과가 나옵니다. 각 값이 어떤 원본 컬럼에서 왔는지 알 수 있으며, 컬럼 내에서의 순서도 그대로 유지됩니다.

--- 추출된 모든 비-NaN 데이터 (순서 유지) ---
{'value': 0.1, 'original_column': 'A'}
{'value': 0.03, 'original_column': 'A'}
{'value': -0.06, 'original_column': 'A'}
{'value': 0.12, 'original_column': 'B'}
{'value': -0.05, 'original_column': 'C'}
{'value': 0.03, 'original_column': 'C'}
{'value': 0.07, 'original_column': 'C'}
{'value': 0.11, 'original_column': 'D'}

3.2. 데이터 재구성 및 새로운 데이터프레임 생성

이제 추출된 데이터를 바탕으로 최종 목표 데이터프레임을 생성합니다. 값만으로 이루어진 데이터프레임과, 원본 컬럼 정보를 담은 데이터프레임을 별도로 생성하여 연관성을 명확히 보여줍니다.

# 2. 값과 원본 컬럼명을 각각의 리스트로 분리
flat_values = [item['value'] for item in all_extracted_data]
flat_original_cols = [item['original_column'] for item in all_extracted_data]

# 3. 원하는 컬럼 개수로 데이터 재구성
output_num_cols = 2 # 최종 데이터프레임의 원하는 컬럼 개수
total_elements = len(flat_values)

# 최종 행 개수 계산. 총 요소 개수가 output_num_cols로 나누어 떨어지지 않으면 오류 발생 또는 패딩 필요
if total_elements % output_num_cols != 0:
    print("경고: 전체 유효 값의 개수가 목표 컬럼 개수로 나누어 떨어지지 않습니다. 마지막 행이 불완전할 수 있습니다.")
    # 이 경우 np.nan 등으로 패딩하거나, ValueError를 발생시킬 수 있습니다.
    # 예시 문제에서는 정확히 나누어 떨어집니다.
    
output_num_rows = total_elements // output_num_cols

# NumPy를 이용한 reshape
reshaped_values = np.array(flat_values).reshape(output_num_rows, output_num_cols)
reshaped_original_cols = np.array(flat_original_cols).reshape(output_num_rows, output_num_cols)

# 4. 결과 데이터프레임 생성
# 값으로 구성된 최종 데이터프레임
final_df_values = pd.DataFrame(reshaped_values, columns=['E', 'F'])

# 원본 컬럼 정보로 구성된 데이터프레임 (메타데이터 역할)
# 컬럼명에 '_source'를 붙여 값 데이터프레임과 구분
final_df_source_cols = pd.DataFrame(reshaped_original_cols, columns=['E_source', 'F_source'])

print("\n--- 최종 결과 데이터프레임 (값) ---")
print(final_df_values)

print("\n--- 최종 결과 데이터프레임 (원본 컬럼 정보) ---")
print(final_df_source_cols)

이 코드를 실행하면 다음과 같은 최종 결과 데이터프레임을 얻을 수 있습니다. 이는 사용자가 원했던 형식과 정확히 일치하며, 값의 원본 컬럼 정보도 함께 확인할 수 있습니다.

--- 최종 결과 데이터프레임 (값) ---
      E     F
0  0.10 -0.05
1  0.03  0.12
2 -0.06  0.03
3  0.11  0.07

--- 최종 결과 데이터프레임 (원본 컬럼 정보) ---
  E_source F_source
0        A        C
1        A        B
2        A        C
3        D        C

보시는 바와 같이, -0.06은 ‘A’ 컬럼에서, 0.12는 ‘B’ 컬럼에서 왔음을 final_df_source_cols를 통해 명확히 확인할 수 있습니다. 이렇게 두 개의 데이터프레임을 병렬적으로 관리하면 데이터의 무결성과 추적성을 동시에 확보할 수 있습니다.

4. 데이터 변환 시 고려사항 및 최적화 팁

이러한 복잡한 데이터 재구성 작업 시 다음 사항들을 고려하면 더욱 효율적이고 견고한 코드를 작성할 수 있습니다.

4.1. 요구사항의 명확화

데이터 변환 작업을 시작하기 전에, 최종 결과의 형태, 각 값의 출처, 유지해야 할 속성(예: 순서, 고정된 행 길이) 등을 명확하게 정의하는 것이 중요합니다. 이 과정이 명확할수록 문제 해결에 필요한 단계를 정확히 파악할 수 있습니다.

4.2. 성능 최적화

대규모 데이터프레임을 다룰 때는 `for` 루프를 사용한 반복적인 접근보다는 Pandas나 NumPy의 벡터화된 연산을 활용하는 것이 성능에 유리합니다. 이 문제의 경우 `dropna()`는 Pandas의 최적화된 메서드이며, `numpy.reshape` 또한 효율적인 연산을 제공합니다. 하지만 모든 값을 리스트로 추출하는 과정은 데이터 크기에 따라 병목이 될 수 있으므로, 가능하다면 `stack()`과 같은 메서드를 고려해볼 수도 있습니다. 다만 `stack()`은 원하는 출력 형태를 바로 만들지 못할 수 있어 추가적인 `unstack()`이나 `pivot_table` 등의 작업이 필요할 수 있습니다.

4.3. 메타데이터 관리의 중요성

원본 데이터의 정보(이 예시에서는 원본 컬럼명)를 보존해야 할 경우, 위 해결책처럼 별도의 데이터프레임으로 관리하거나, Pandas의 MultiIndex 컬럼을 활용하는 등 명확한 메타데이터 관리 전략을 세우는 것이 중요합니다. 이렇게 하면 데이터 변환 후에도 원본 맥락을 잃지 않고 데이터를 활용할 수 있습니다.

4.4. 예외 처리 (Edge Cases)

만약 `flat_values`의 길이가 `output_num_cols`로 나누어 떨어지지 않는다면 `reshape` 과정에서 `ValueError`가 발생할 수 있습니다. 이 경우, 남는 공간을 `np.nan` 등으로 채우거나, 마지막 행을 의도적으로 불완전하게 만드는 등의 예외 처리 로직을 추가해야 합니다. 실제 분석 환경에서는 이러한 엣지 케이스를 반드시 고려해야 합니다.

이 문서를 통해 Pandas 데이터프레임에서 NaN 값을 포함한 복잡한 재구성 요구사항을 해결하는 데 도움이 되셨기를 바랍니다. 효율적인 데이터 조작은 데이터 분석의 핵심이며, Pandas는 이를 위한 강력한 도구를 제공합니다!

댓글 남기기