Code › codeit-ai-sprint

Pandas 마스킹과 groupby

Pandas에서 데이터 필터링, 값을 만들기, 그룹 단위로 집계

오늘 수업은 Pandas에서 조건으로 데이터를 정제하고, 새로운 값을 만들고, 그룹 단위로 집계하는 내용이었다. 어제는 DataFrame, Series, loc, iloc, merge 같은 기본 동작을 봤다면, 오늘은 그 도구들을 실제 전처리와 분석에 필요한 예시와 함께 만들어보는 과정을 진행했다.

pandas는 같은 결과를 만드는 코드가 너무 많아서 헷갈렸다. 마스킹도 대괄호로 할 수 있고, loc로 할 수 있고, isin을 쓸 수도 있다. percentage 컬럼을 만드는 코드도 map, astype, apply가 모두 가능했다. 그래서 오늘은 문법을 외우기보다, 각 방식이 어떤 원리로 돌아가는지 조금 더 중점을 두고 봤다.


조건으로 행을 고르는 마스킹

Pandas에서 마스킹은 조건식으로 True, False 값을 만든 뒤, True인 행만 고르는 방식이다. 먼저 간단한 DataFrame을 만들었다.

import pandas as pd

table2 = {
    "일자": ["2021-12-06", "2021-12-07", "2021-12-08", "2021-12-09"],
    "가격": [1000, 3000, 2000, 1000],
    "구매여부": [False, True, True, True],
    "제품": ["gum", "snack", "beverage", "gum"],
}

df2 = pd.DataFrame(table2)
df2

예를 들어 제품이 gum도 아니고 snack도 아닌 행을 고르려면 조건을 두 개 만들고, 두 조건을 &로 묶을 수 있다.

df2[(df2["제품"] != "gum") & (df2["제품"] != "snack")]

같은 조건은 isin으로도 쓸 수 있다.

df2[~df2["제품"].isin(["gum", "snack"])]

isin은 값이 목록 안에 들어 있는지 검사한다. 기본 결과는 제품이 gum 또는 snack인 행이고, 여기에 ~를 붙이면 boolean 결과가 뒤집혀서 gum도 아니고 snack도 아닌 행만 남는다.

mask = df2["제품"].isin(["gum", "snack"])

print(mask)
print(~mask)

isin 자체는 목록 안의 값 중 하나와 일치하면 True를 반환한다. 그 결과 전체를 ~로 뒤집으면 목록 안의 어떤 값과도 일치하지 않는 행만 남는다.


loc로 조건에 맞는 위치에 값 넣기

마스킹은 행을 고르는 데만 쓰는 것이 아니라, 조건에 맞는 위치에 값을 넣을 때도 쓴다.

condition1 = (df2["제품"] == "gum") & (df2["구매여부"] == 0)

df2.loc[condition1, "관심대상"] = True
df2.loc[~condition1, "관심대상"] = False

df2

이 코드는 condition1이 True인 행의 관심대상 컬럼에 True를 넣고, 나머지 행에는 False를 넣는다. 기존에 관심대상 컬럼이 없더라도, loc로 값을 대입하면 Pandas가 새 컬럼을 만든다.

여기서 헷갈렸던 부분은 loc의 괄호 안 구조였다. loc는 일반 함수 호출이 아니라 인덱서다. 그래서 df.loc[행 선택, 열 선택] 형태로 읽는 것이 맞았다.

df.loc[row_selector, column_selector]

첫 번째 자리는 행을 고르는 기준이고, 두 번째 자리는 열을 고르는 기준이다. 첫 번째 자리에 boolean Series를 넣으면 True인 행만 선택하고, 두 번째 자리에 컬럼 이름을 넣으면 해당 컬럼만 선택한다.

# 조건에 맞는 행 전체
df2.loc[condition1]

# 조건에 맞는 행의 특정 컬럼
df2.loc[condition1, "관심대상"]

Python에는 Java나 C++식 함수 오버로딩이 없다. Pandas의 loc가 여러 형태를 받는 것은, 하나의 인덱서가 전달된 값의 타입을 보고 해석하기 때문이다. 문자열이면 라벨로 보고, 리스트면 여러 라벨로 보고, boolean Series면 행 선택 마스크로 처리한다.

그래서 loc를 볼 때는 함수 원형을 외우기보다 df.loc[행, 열]이라는 틀을 먼저 잡는 게 낫다.


map, astype, apply로 컬럼 만들기

비율을 계산해서 percentage 컬럼을 만드는 방법도 여러 가지였다.

df_tips["percentage"] = df_tips["percentage"].map(lambda x: f"{x:.2f}%")

map은 Series의 각 원소에 함수를 하나씩 적용한다. 위 코드는 이미 계산되어 있는 percentage 값 하나하나를 받아 소수점 둘째 자리 문자열로 바꾼 뒤, 뒤에 %를 붙인다.

같은 결과는 벡터화 연산으로도 만든다.

df_tips["percentage"] = (
    round((df_tips["tip"] / df_tips["total_bill"]) * 100, 2)
    .astype(str)
    + "%"
)

이 방식은 tip 컬럼과 total_bill 컬럼 전체를 한 번에 계산한다. Pandas와 NumPy 계열 코드에서는 벡터화 연산을 먼저 고려하는데, 코드가 컬럼 단위로 움직이고 내부 연산도 배열 단위로 처리되기 때문이다.

여러 컬럼을 한 행 안에서 함께 읽어야 할 때는 apply를 쓴다.

df_tips["percentage"] = df_tips.apply(
    lambda x: f"{x.tip / x.total_bill * 100:.2f}%",
    axis=1,
)

axis=1을 주면 apply는 각 행을 하나씩 함수에 넘긴다. 그래서 x.tip과 x.total_bill처럼 한 행 안의 여러 컬럼을 함께 읽어서 값을 만들 수 있다.

df_tips["percentage"] = df_tips.apply(
    lambda x: str(round(x.tip / x.total_bill * 100, 2)) + "%",
    axis=1,
)

이 코드도 결과는 비슷하지만, f-string으로 포맷을 지정하는 쪽이 소수점 자리수를 일정하게 맞추기 좋다.

오늘 기준으로 정리하면 이렇다.

방식적용 단위주로 쓰는 상황
mapSeries의 원소 하나씩한 컬럼의 값을 매핑하거나 포맷팅할 때
벡터화 연산컬럼 전체컬럼끼리 계산할 때
apply axis=1행 하나씩한 행 안의 여러 컬럼을 읽어 계산할 때

같은 결과가 나와도 동작 단위가 다르다. 그 차이를 모르면 Pandas 코드가 전부 비슷해 보이는데, 동작 단위를 기준으로 나누면 선택 기준이 생긴다.


groupby와 agg

groupby는 데이터를 특정 컬럼 기준으로 나눈 뒤, 각 그룹에 같은 집계 함수를 적용하는 방식이다. 예를 들어 제품별 평균 가격을 보고 싶다면 제품으로 묶고 가격의 평균을 계산한다.

df2.groupby("제품")["가격"].mean()

여러 집계를 한 번에 보고 싶으면 agg를 쓸 수 있다.

df2.groupby("제품")["가격"].agg(["count", "sum", "mean"])

여러 컬럼에 서로 다른 집계를 적용할 수도 있다.

df2.groupby("제품").agg({
    "가격": ["count", "sum", "mean"],
    "구매여부": "sum",
})

여기서 구매여부는 True와 False 값인데, Python에서는 True가 1, False가 0처럼 계산될 수 있다. 그래서 sum을 쓰면 True인 행의 개수를 세는 식으로 활용할 수 있다.

apply는 groupby 뒤에서도 쓸 수 있다. 그룹별로 DataFrame 조각을 받은 뒤, 직접 정의한 계산을 적용하는 방식이다.

def price_range(group):
    return group["가격"].max() - group["가격"].min()


df2.groupby("제품").apply(price_range)

단순한 합계, 평균, 개수는 agg가 더 읽기 쉽고, 그룹 안에서 여러 단계를 직접 처리해야 하면 apply를 쓸 수 있다.


describe와 pivot_table

describe는 숫자형 컬럼의 기초 통계를 빠르게 확인할 때 쓴다.

df2.describe()

개수, 평균, 표준편차, 최솟값, 사분위수, 최댓값이 한 번에 나온다. EDA 초반에는 데이터가 어느 범위에 있고, 이상하게 큰 값이나 작은 값이 있는지 먼저 보는 용도로 쓸 수 있다.

pivot_table은 행과 열의 기준을 정하고, 값 컬럼을 집계해서 표로 다시 만드는 함수다.

pd.pivot_table(
    df2,
    index="제품",
    values="가격",
    aggfunc="mean",
)

그룹별 요약이라는 점에서는 groupby와 닮았지만, pivot_table은 결과를 표 형태로 재배치하는 데 초점이 있다. 엑셀의 피벗 테이블을 코드로 만든다고 이해하면 된다.


Seaborn과 pairplot

시각화에서는 Seaborn도 나왔다. Matplotlib이 기본 그래프 도구라면, Seaborn은 통계적 시각화를 더 짧은 코드로 그릴 수 있게 해준다.

pairplot은 데이터셋의 여러 숫자형 변수들을 서로 짝지어 산점도와 분포로 보여준다.

import seaborn as sns

sns.pairplot(df_tips)

변수가 많아지면 모든 관계를 표로 읽기 어렵다. pairplot은 변수들 사이의 관계를 빠르게 훑어보는 용도로 쓸 수 있다. 다만 그래프가 많아질수록 해석도 복잡해지므로, EDA에서는 먼저 보고 싶은 질문을 정하고 필요한 그래프를 고르는 쪽이 낫다.


호텔 예약 데이터 EDA 과제

마지막에는 Kaggle의 호텔 예약 데이터로 EDA를 진행하는 과제가 나왔다. 목표는 호텔 예약 내역을 분석해서 취소율을 낮추기 위한 단서를 찾는 것이다.

아직 과제는 진행 중이다. 먼저 결측치를 확인하고, 예약 취소 여부를 기준으로 데이터를 나눈 뒤, 리드타임, 객실 타입, 고객 유형, 예약 채널 같은 변수와 취소율의 관계를 평균, 표준편차 등을 토대로 보고 있다. 내일은 EDA를 상관계수 등으로 좀 더 분석하고 그래프도 좀 그려서 시각화 할 예정이다.