본문 바로가기

개발/파이썬

데이터 결합과 재구조화 🔄 - 자주 쓰는 명령어로 배우는 Polars #5

반응형

안녕하세요! '자주 쓰는 명령어로 배우는 Polars' 시리즈의 다섯 번째 글입니다. 지난 글에서는 데이터 그룹화와 집계에 대해 알아보았는데요. 오늘은 데이터 결합(Joining)재구조화(Reshaping) 기능에 대해 알아보도록 하겠습니다.

데이터 분석을 하다 보면 여러 데이터셋을 하나로 합치거나, 데이터의 형태를 변환해야 하는 경우가 자주 있습니다. 특히 실무에서는 여러 소스의 데이터를 통합하고 분석하기 좋은 형태로 변환하는 작업이 매우 중요한데요. Polars는 이러한 작업을 효율적으로 수행할 수 있는 다양한 기능을 제공하고 있습니다. 😊

💡 여기서 사용하는 Polars 버전은 1.5입니다. polars 버전이 1 미만이면 예시 코드가 제대로 동작하지 않을 수 있습니다.

먼저 필요한 라이브러리를 임포트하고 예제 데이터를 불러와 보겠습니다. (아직 설치를 안 하신 분들이 있다면 지난 글을 참고해서 먼저 설치해 주세요 🛠️)

예제 데이터는 아래 파일을 다운받아주세요.

Pokemon.csv
0.04MB

import polars as pl
from datetime import datetime, timedelta

# 메인 데이터 불러오기
df = pl.read_csv("Pokemon.csv")

# 예시를 위한 추가 데이터프레임들 생성
moves_df = pl.DataFrame({
    "timestamp": [
        "2024-01-01 09:00:00",
        "2024-01-01 10:20:00",
        "2024-01-01 09:30:00"
    ],
    "Name": ["Pikachu", "Charizard", "Mewtwo"],
    "Signature_Move": ["Thunder", "Flamethrower", "Psychic"],
    "base_power": [110, 90, 90]
})

stats_df = pl.DataFrame({
    "Type": ["Electric", "Fire", "Water"],
    "Resistance": ["Ground", "Water", "Grass"],
    "Weakness": ["Ground", "Rock", "Electric"]
})

# 시계열 데이터 예시용 데이터프레임
battle_logs = pl.DataFrame({
    "timestamp": [
        "2024-01-01 10:00:00",
        "2024-01-01 10:15:00",
        "2024-01-01 10:30:00",
        "2024-01-01 10:45:00"
    ],
    "pokemon": ["Pikachu", "Charizard", "Pikachu", "Mewtwo"],
    "power_used": [95, 85, 92, 90]
})

1. 데이터프레임 결합하기 🤝

기본 join 연산 이해하기

데이터프레임을 결합하는 방법 중 가장 많이 사용되는 것이 join 입니다. SQL의 JOIN과 비슷한 개념으로, 두 데이터프레임의 공통 키를 기준으로 데이터를 결합합니다.

# left join 예시
pokemon_moves = df.join(
    moves_df,
    on="Name",
    how="left"
)

print("포켓몬과 기술 정보 결합 (left join):")
print(pokemon_moves.select(["Name", "Type 1", "Signature_Move"]).head())

실행결과

포켓몬과 기술 정보 결합 (Left Join)

Polars는 다음과 같은 join 타입을 지원합니다:

  • left: 왼쪽 데이터프레임 기준 결합 (기준 데이터는 모두 유지)
  • inner: 양쪽 모두 있는 데이터만 결합
  • outer: 양쪽 모든 데이터 유지하며 결합
  • cross: 모든 가능한 조합으로 결합
  • semi: 오른쪽 데이터프레임에 있는 데이터만 필터링
  • anti: 오른쪽 데이터프레임에 없는 데이터만 필터링
# innter join 예시
type_matchups = pokemon_df.join(type_df, left_on="Type 1", right_on="Type", how="inner")

print("\n타입별 상성 정보 (inner join):")
print(type_matchups)

실행결과

타입별 상성 정보 (Inner Join)

asof_join으로 시계열 데이터 결합하기

시계열 데이터를 다룰 때는 정확한 시간 매칭이 아닌, 가장 가까운 시간을 기준으로 결합해야 할 때가 있습니다.

# 전투 로그와 포켓몬 정보 결합
battle_stats = battle_logs.join_asof(
    pokemon_moves, left_on="timestamp", right_on="timestamp", by_left="pokemon", by_right="Name", strategy="backward"
)

print("\n시간 기반 전투 통계:")
print(
    battle_stats.select(["timestamp", "pokemon", "power_used", "Signature_Move"]).head()
)

실행결과

시간 기반 전투 통계 (join_asof)

concat으로 데이터 이어 붙이기

데이터를 세로나 가로로 이어 붙일 때는 concat을 사용합니다. 특히 시계열 데이터나 여러 파일에서 읽어온 데이터를 합칠 때 유용합니다.

세로 방향으로 결합할 때는 두 데이터프레임이 동일한 이름의 칼럼을 가지고 있어야 합니다.

# 세로 방향 결합 (여러 세대의 포켓몬 데이터 합치기)
gen1 = df.filter(pl.col("Generation") == 1).head(5)
gen2 = df.filter(pl.col("Generation") == 2).head(5)

combined_gens = pl.concat(
    [gen1, gen2], how="vertical"
)

print("\n1,2세대 포켓몬 결합:")
print(combined_gens.select(["Name", "Generation"]).head(10))

실행결과

1, 2세대 포켓몬 결합 (concat)

가로 뱡향으로 결합할 때, concat은 join과 달리 단순히 데이터를 순서대로 붙이기만 합니다. 따라서 데이터의 순서가 매우 중요합니다.

# 가로 방향 결합 (능력치와 기술 정보 옆으로 붙이기)
stats_and_moves = pl.concat(
    [df.select(["Name", "HP", "Attack"]), moves_df.select([pl.col("Name").alias("Move_Name"), "Signature_Move"])],
    how="horizontal",
)

print("\n능력치와 기술 정보 결합:")
print(stats_and_moves.head())

실행결과

능력치와 기술 정보 결합 (concat)

위 예시에서 가로 방향 결합 시 첫 번째 데이터프레임의 Name과 두 번째 데이터프레임의 Move_Name이 서로 다른 것을 볼 수 있습니다. concat은 단순히 데이터프레임을 옆으로 붙이기만 할 뿐, join처럼 키 값을 기준으로 매칭하지 않습니다.

데이터 결합 시 알아두면 좋은 팁들 💡

데이터를 결합하고 재구조화하는 과정에서 자주 발생하는 문제들이 있습니다. 아래에서 자주 발생하는 문제들과 해결 방법을 살펴보도록 하겠습니다.

  1. 키 불일치 문제
# 조인 전 키 컬럼 전처리
df1 = df1.with_columns([
   pl.col("key").str.strip().str.to_lowercase()
])
  1. null 값 처리
# null 값 처리 전략
result = df.join(
   other_df,
   on="key",
   how="left"
).fill_null(
   strategy="forward",
)
  1. 데이터 타입 불일치
# 데이터 타입 통일
df1 = df1.with_columns([
   pl.col("numeric_col").cast(pl.Float64)
])

2. 피벗 테이블과 재구조화 📊

피벗 테이블은 데이터를 요약하고 재구성하는데 유용합니다. 엑셀에서 본 적 있는 그 '피벗 테이블'을 떠올려보시면 됩니다! 😊

피벗 테이블

# 세대별, 타입별 평균 능력치 피벗 테이블
pivot_stats = df.pivot(
    values="HP", index="Generation", on="Type 1", aggregate_function="mean"
)

print("세대별-타입별 평균 HP:")
print(pivot_stats.head())

실행결과

세대별-타입별 평균 HP (pivot)

unpivot으로 데이터 재구조화하기

때로는 피벗 테이블의 반대 작업이 필요할 때가 있습니다. 즉, 여러 칼럼에 분산된 값들을 하나의 칼럼으로 모아서 분석할 수 있습니다. 이런 경우 unpivot 함수를 사용합니다.

unpivoted_stats = df.unpivot(
    on=["HP", "Attack", "Defense", "Speed"],  # 변환할 컬럼들
    index=["Name", "Type 1"],  # 유지할 식별자 컬럼들
    variable_name="stat_type",  # 컬럼 이름을 저장할 새 컬럼
    value_name="stat_value",  # 값을 저장할 새 컬럼
)

print("\n재구조화된 데이터 형태:")
print(unpivoted_stats.filter(pl.col("Name") == "Bulbasaur"))

실행결과

재구조화된 데이터 형태

3. 데이터 재구조화 🚀

데이터 재구조화는 데이터의 모양을 우리가 분석하기 쉬운 형태로 바꾸는 작업입니다.

explode로 리스트 다루기

explode 함수는 리스트 형태의 데이터를 개별적인 행으로 분리하여, 각 요소를 별도의 행으로 변환하는 데 사용됩니다. 이를 통해 데이터 분석이나 처리 과정에서 리스트 내의 각 요소를 독립적으로 다룰 수 있게 됩니다.

# 복합 타입을 가진 포켓몬 분석
df_with_types = df.with_columns(
    [
        pl.when(pl.col("Type 2").is_not_null())
        .then(pl.concat_list([pl.col("Type 1"), pl.col("Type 2")]))
        .otherwise(pl.concat_list([pl.col("Type 1")]))
        .alias("Types")
    ]
)
print("\n리스트 형태의 Types 컬럼생성")
print(df_with_types.select(["Name", "Type 1", "Type 2", "Types"]).head())

# 타입 리스트 풀어내기
exploded_types = df_with_types.explode("Types")
print("\n타입별 분석을 위한 데이터:")
print(exploded_types.select(["Name", "Types"]).head())

실행결과

리스트 타입 풀어내기 (explode)

구조화된 데이터 처리

때로는 칼럼이 구조화된(structured) 데이터, 즉 하나의 칼럼 안에 여러 필드가 중첩된 형태로 존재할 때가 있습니다. 예를 들어 'stats' 컬럼 하나에 'HP', 'Attack', 'Defense' 등의 세부 필드가 포함되어 있는 형태입니다. 이는 JSON과 같은 계층적 구조를 가진 데이터를 데이터프레임으로 가져올 때 자주 발생합니다.

하지만 분석을 위해서는 이 구조화된 데이터를 다시 평평하게(flat) 펼쳐야 할 때가 있습니다. 이때 사용하는 것이 unnest 함수입니다. unnest는 구조화된 칼럼을 개별 칼럼으로 분리해 주어 각각의 값에 쉽게 접근할 수 있게 해 줍니다.

structured_stats = df.with_columns(
    [
        pl.struct(
            [
                pl.col("Attack"),
                pl.col("Defense"),
                pl.col("HP"),
                pl.col("Speed")
            ]
        ).alias("stats_groups")
    ]
).select(["Name", "stats_groups"])
print("\n능력치를 구조화된 형태로 변환")
print(
    structured_stats.head()
)

unstruct = structured_stats.unnest("stats_groups")
print("\n구조화된 능력치 데이터:")
print(unstruct.select(["Name", "Attack", "Defense", "HP", "Speed"]).head())

실행결과

구조화된 능력치 데이터 풀어내기 (unnest)


이번 글에서는 Polars의 데이터 결합과 재구조화 기능에 대해 자세히 알아보았습니다. 다음 글에서는 성능 최적화와 문자열, 시계열 데이터 처리에 대해 알아보도록 하겠습니다. 긴 글 읽어주셔서 감사합니다! 🙇‍♂️

📚 참고자료

반응형