Code › codeit-ai-sprint

Pandas DataFrame과 데이터 다루기

Pandas DataFrame으로 데이터를 선택하고 합치고 정리하는 기본 흐름

오늘 수업은 Matplotlib을 마무리하고 Pandas로 넘어갔다. Matplotlib이 데이터를 그림으로 확인하는 도구였다면, Pandas는 데이터를 표 형태로 읽고, 고르고, 합치고, 정리하는 도구였다.

NumPy 수업에서는 배열의 shape와 axis를 계속 봤는데, Pandas로 오니 같은 데이터에도 행 이름, 열 이름, 인덱스 같은 의미가 붙었다. 숫자 배열을 직접 다루는 단계에서, 분석하기 좋은 데이터 표를 다루는 단계로 넘어온 셈이다.


DataFrame을 쓰는 이유

Pandas DataFrame은 행과 열을 가진 2차원 데이터 구조다. 데이터베이스 테이블이나 Excel 시트를 떠올리면 이해하기 쉽고, 각 열에는 컬럼 이름이 붙으며 각 행에는 인덱스가 붙는다.

DataFrame의 장점은 구조화된 데이터를 코드로 반복 처리할 수 있다는 점이다. CSV나 Excel 파일을 읽고 저장할 수 있고, 결측치 처리, 정렬, 필터링, 컬럼 추가 같은 전처리 작업도 코드로 남길 수 있다. Excel에서도 비슷한 일을 할 수 있지만, 같은 처리를 반복하거나 다른 시스템과 연결할 때는 Pandas 쪽이 훨씬 편하다.

대용량 데이터 처리에서도 Pandas가 Excel보다 유리한 경우가 많다. 다만 Pandas가 무조건 100GB 데이터를 쉽게 처리한다는 뜻은 아니고, 실제 한계는 메모리 용량, 데이터 타입, 연산 방식에 따라 달라진다. 오늘은 Excel보다 코드 기반 처리가 유연하고, NumPy 기반이라 수학과 통계 연산을 이어 쓰기 좋은 도구로 정리했다.

import pandas as pd

# CSV 읽기
df = pd.read_csv("data.csv")

# 앞부분 확인
df.head()

# 기본 정보 확인
df.info()

# 기초 통계 확인
df.describe()

데이터 분석에서 중요한 건 한 번 본 데이터를 계속 같은 기준으로 다시 볼 수 있어야 한다는 점이다. Pandas는 그 과정을 코드로 남긴다.


DataFrame과 Series

Series는 1차원 데이터를 다루는 구조다. 하나의 컬럼이나 하나의 행처럼 한 줄짜리 데이터를 표현할 때 Series가 나온다.

DataFrame은 2차원 데이터를 다룬다. 여러 Series가 모이면 행과 열을 가진 표가 된다. 오늘 실습에서는 과목별 점수를 담은 딕셔너리를 DataFrame으로 바꾸면서 이 차이를 봤다.

exam_data = {
    "수학": [90, 80, 70],
    "영어": [98, 89, 95],
    "음악": [85, 95, 100],
    "체육": [100, 90, 90],
}

students = ["서준", "우현", "인아"]
df = pd.DataFrame(exam_data, index=students)

df

DataFrame에서 하나의 컬럼을 꺼내면 Series가 된다.

math_scores = df["수학"]

print(type(df))
print(type(math_scores))

정리하면 DataFrame은 행과 열이 있는 표이고, Series는 그 표에서 한 컬럼이나 한 행을 꺼낸 1차원 데이터다. Pandas를 쓰다 보면 어떤 연산의 결과가 DataFrame인지 Series인지에 따라 다음에 쓸 수 있는 메서드와 모양이 달라지므로, 이 구분은 계속 확인해야 한다.


inplace 옵션은 처음부터 습관으로 만들지 않기

Pandas에는 inplace 옵션을 가진 함수가 있다. inplace=True를 주면 결과를 새 객체로 반환하는 대신 원본 DataFrame을 직접 바꾸도록 요청한다.

df5 = df.copy()

df5.drop(["영어", "음악"], axis=1, inplace=True)

df5

코드가 짧아 보이지만, 처음 배울 때는 이 방식을 기본으로 두지 않는 편이 낫다. 원본이 바뀌는 시점이 함수 호출 안에 숨어 있어서 디버깅이 어려워지고, 중간에 복사본과 원본이 섞이면 어떤 객체가 바뀌었는지 추적하기 힘들다.

그래서 결과를 다시 변수에 할당하는 방식을 먼저 쓰는 게 더 명확하다.

df5 = df.copy()

df5 = df5.drop(["영어", "음악"], axis=1)

df5

여기서 axis=1은 열 방향을 뜻한다. 영어와 음악은 컬럼 이름이므로 axis=1로 삭제하고, 행을 삭제할 때는 axis=0을 사용한다.

# 행 삭제 예시
df_without_woohyun = df.drop(["우현"], axis=0)

오늘 실습에서는 inplace 자체보다 원본을 언제 바꿀 것인지가 더 중요했다. 원본을 보존하려면 copy로 복제한 뒤 작업하고, 변경 결과는 다시 변수에 담는 식으로 흐름을 분명하게 두는 게 낫다.


loc, iloc, 일반 대괄호 선택

Pandas에서 행과 열을 선택하는 방식은 처음에 가장 헷갈리는 부분이었다. 기준이 이름인지 위치인지, 행을 고르는지 열을 고르는지에 따라 문법이 달라진다.

loc는 이름 기반 선택이다. 행 인덱스 이름과 컬럼 이름을 사용한다.

# 우현 행 전체 선택
df.loc["우현"]

# 우현의 영어 점수 선택
df.loc["우현", "영어"]

# 여러 행과 여러 열 선택
df.loc[["서준", "인아"], ["수학", "음악"]]

iloc는 위치 기반 선택이다. 0부터 시작하는 정수 위치를 사용한다.

# 두 번째 행 전체 선택
df.iloc[1]

# 두 번째 행, 두 번째 열 선택
df.iloc[1, 1]

# 첫 번째부터 두 번째 행까지, 첫 번째부터 세 번째 열까지 선택
df.iloc[0:2, 0:3]

일반 대괄호 선택은 주로 컬럼 선택에 쓴다. df[“수학”]처럼 쓰면 수학 컬럼을 가져온다.

# 컬럼 선택
df["수학"]

# 여러 컬럼 선택
df[["수학", "영어"]]

주의할 점은 df[“우현”]이다. 위 예시에서 우현은 행 인덱스 이름이지 컬럼 이름이 아니다. 그래서 df[“우현”]은 우현이라는 컬럼을 찾으려고 하고, 그런 컬럼이 없으면 에러가 난다. 행 이름으로 접근하려면 df.loc[“우현”]을 써야 한다.

# 행 이름으로 접근
row = df.loc["우현"]

# 컬럼 이름으로 접근
column = df["영어"]

슬라이싱도 문맥에 따라 헷갈릴 수 있다. df[1:3]은 행 위치 기준 슬라이싱처럼 동작하지만, 컬럼 선택과 섞어 쓰면 읽는 사람이 기준을 다시 확인해야 한다.

# 행 위치 기준 슬라이싱처럼 동작한다.
df[1:3]

그래서 행과 열을 분명하게 지정해야 할 때는 loc 또는 iloc를 쓰는 편이 안전하다.


loc 리스트 선택과 isin 필터링

인덱스에서 특정 행만 고를 때는 loc에 리스트를 넘길 수 있다.

years = ["2013", "2014", "2018"]

selected = df_california.loc[years]

이 방식은 인덱스 이름을 기준으로 원하는 행을 직접 집어오는 코드다. 다만 리스트 안에 실제로 존재하지 않는 인덱스가 섞여 있으면 에러가 날 수 있다.

isin은 조건 필터링 방식이다. 데이터에 없는 값이 섞여 있어도 존재하는 값만 골라온다.

years = ["2013", "2014", "2018", "2099"]

selected = df_california[df_california.index.isin(years)]

isin은 인덱스뿐 아니라 일반 컬럼에도 쓸 수 있다.

cities = ["서울", "부산"]

selected = df[df["도시"].isin(cities)]

loc에 리스트를 넘기는 방식은 인덱스 이름을 정확히 알고 있을 때 쓰고, isin은 필터 조건으로 쓰면 된다. 특히 값이 실제 데이터 안에 있는지 확실하지 않을 때는 isin이 더 다루기 쉽다.


체인 인덱싱보다 한 번에 조준하기

Pandas에서는 다음처럼 두 단계로 값을 꺼낼 수 있다.

df_california["total_rooms"]["2012"]
df_california["total_rooms"].loc["2012"]
df_california.loc["2012"]["total_rooms"]

이런 코드는 동작할 수 있지만, 중간에 Series를 하나 만든 뒤 다시 값을 꺼내는 방식이다. 값을 읽을 때는 지나갈 수 있어도, 값을 수정할 때는 원본이 바뀌는지 복사본이 바뀌는지 헷갈릴 수 있다.

그래서 행과 열을 함께 지정할 때는 쉼표로 한 번에 지정하는 방식을 기본으로 둔다.

# 이름 기반 선택
df_california.loc["2012", "total_rooms"]

# 위치 기반 선택
df_california.iloc[2012, 3]

loc로 “2012”에 접근할 수 있었던 이유는 해당 데이터에서 인덱스 값을 문자열로 변환했기 때문이다. 인덱스가 정수라면 loc에는 정수 인덱스 이름을 넣어야 하고, 행 순서로 접근하려면 iloc를 써야 한다.

오늘 배운 기준으로는 한 번에 행과 열을 지정할 수 있으면 그렇게 쓰는 게 맞다. 두 번 나눠서 고르는 코드는 읽는 쪽에서도 중간 결과의 타입을 다시 생각해야 하고, 수정 코드에서는 경고의 원인이 될 수 있다.


concat, join, merge

여러 DataFrame을 합치는 방법도 나왔다. 이름은 비슷하지만 쓰임이 다르다.

concat은 DataFrame을 위아래 또는 좌우로 이어 붙인다. 같은 구조의 데이터를 여러 조각으로 나눠 읽은 뒤 하나로 붙일 때 자주 쓴다.

first = pd.DataFrame({"name": ["A", "B"], "score": [90, 80]})
second = pd.DataFrame({"name": ["C", "D"], "score": [70, 60]})

pd.concat([first, second], axis=0)

axis=0이면 행 방향으로 이어 붙이고, axis=1이면 열 방향으로 붙인다.

left = pd.DataFrame({"name": ["A", "B"]})
right = pd.DataFrame({"score": [90, 80]})

pd.concat([left, right], axis=1)

join은 인덱스를 기준으로 두 DataFrame을 합친다. 두 DataFrame의 인덱스가 맞아야 값이 나란히 붙는다.

left = pd.DataFrame({"score": [90, 80]}, index=["서준", "우현"])
right = pd.DataFrame({"club": ["축구", "음악"]}, index=["서준", "우현"])

left.join(right)

merge는 특정 컬럼 값을 기준으로 합친다. SQL의 join처럼 key 컬럼을 기준으로 두 표를 합칠 때 쓴다.

students = pd.DataFrame({
    "student_id": [1, 2, 3],
    "name": ["서준", "우현", "인아"],
})

scores = pd.DataFrame({
    "student_id": [1, 2, 4],
    "score": [90, 80, 70],
})

pd.merge(students, scores, on="student_id", how="inner")

merge의 how 옵션은 어떤 기준으로 행을 남길지 정한다.

pd.merge(students, scores, on="student_id", how="left")
pd.merge(students, scores, on="student_id", how="right")
pd.merge(students, scores, on="student_id", how="outer")
pd.merge(students, scores, on="student_id", how="inner")
방식남기는 행
left merge왼쪽 DataFrame의 행을 모두 남김
right merge오른쪽 DataFrame의 행을 모두 남김
outer merge양쪽에 있는 키를 모두 남김
inner merge양쪽에 모두 존재하는 키만 남김

오늘은 join과 merge의 차이를 인덱스 기준인지 컬럼 기준인지로 먼저 나눠서 기억했다. join은 인덱스가 맞아야 값이 붙고, merge는 on으로 지정한 컬럼 값이 맞아야 행이 연결된다.


누락된 데이터 확인과 처리

Pandas에서는 결측치를 확인하고 처리하는 함수도 배웠다.

df.isnull()

pd.isnull(df)

# 컬럼별 결측치 개수
df.isnull().sum()

결측치가 있는 행을 제거할 때는 dropna를 쓴다.

# 결측치가 하나라도 있는 행 제거
cleaned = df.dropna()

# 특정 컬럼 기준으로 결측치가 있는 행 제거
cleaned = df.dropna(subset=["score"])

결측치를 특정 값으로 채울 때는 fillna를 쓴다.

filled = df.fillna(0)

앞의 값으로 채우는 ffill, 뒤의 값으로 채우는 bfill도 있다.

forward_filled = df.ffill()
backward_filled = df.bfill()

시계열 데이터처럼 앞뒤 순서가 의미 있는 데이터에서는 ffill이나 bfill을 쓸 수 있다. 반대로 순서가 의미 없는 데이터에 앞뒤 값을 그대로 채우면 잘못된 값을 만들 수 있으므로, 어떤 방식으로 채울지는 데이터의 의미를 보고 결정해야 한다.


정렬과 저장

정렬은 sort_values를 사용한다.

# score 기준 오름차순 정렬
sorted_df = df.sort_values(by="score")

# score 기준 내림차순 정렬
sorted_df = df.sort_values(by="score", ascending=False)

작업한 DataFrame은 CSV로 저장할 수 있다.

df.to_csv("result.csv", index=False)

index=False를 넣으면 DataFrame의 인덱스를 별도 컬럼으로 저장하지 않는다. 인덱스 자체가 의미 있는 값이면 저장해야 하고, 단순 행 번호라면 빼는 편이 결과 파일을 보기 좋게 만든다.


정리

오늘 수업은 Pandas를 처음 본격적으로 다루면서 DataFrame, Series, 인덱싱, 결합, 결측치 처리까지 한 번에 지나갔다. 함수 이름이 많아서 외울 것처럼 보였지만, 기준은 생각보다 단순했다.

행과 열을 고를 때는 이름 기준인지 위치 기준인지 먼저 정하고, 데이터를 합칠 때는 인덱스 기준인지 컬럼 기준인지 먼저 정한다. 결측치를 처리할 때도 삭제할지 채울지보다 먼저 그 값이 데이터 안에서 어떤 의미인지 확인해야 한다.

Pandas는 단순히 표를 다루는 라이브러리가 아니라, 분석 전에 데이터를 원하는 형태로 준비하는 도구다. 오늘은 그 첫 단계로 DataFrame을 읽고, 고르고, 합치고, 비어 있는 값을 처리하는 기본 흐름을 잡았다.