포스트

NumPy, Pandas 기초 (3) — Series와 DataFrame, 그리고 데이터 첫 점검

pandas의 두 축인 Series와 DataFrame의 구조, 인덱스 정렬이라는 숨은 동작, CSV와 Parquet 읽기, 그리고 데이터를 받자마자 실행해야 할 다섯 줄의 점검 루틴을 정리했습니다.

NumPy, Pandas 기초 (3) — Series와 DataFrame, 그리고 데이터 첫 점검

NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 세 번째 글입니다. 2편의 NumPy 배열 개념 위에서 pandas를 시작합니다.

pandas는 NumPy 배열에 라벨을 붙인 것

pandas의 두 축은 Series(1차원)와 DataFrame(2차원)입니다. 본질은 단순합니다.

  • Series = NumPy 배열 + 인덱스(라벨)
  • DataFrame = 같은 인덱스를 공유하는 Series들의 묶음. 열마다 dtype이 달라도 됩니다.
1
2
3
4
5
6
7
8
9
10
11
import pandas as pd

s = pd.Series([92, 85, 77], index=["김철수", "이영희", "박민수"])
s["이영희"]          # 85 — 위치가 아니라 이름으로 접근

df = pd.DataFrame({
    "student": ["김철수", "이영희", "박민수"],
    "score": [92, 85, 77],
})
df["score"]         # 열 하나를 뽑으면 Series
df["score"].values  # .values로 껍질을 벗기면 NumPy 배열

NumPy가 “위치”로만 데이터를 다룬다면, pandas는 “이름”으로 다룰 수 있게 해줍니다. 2번째 열이 아니라 "score" 열, 1024번째 행이 아니라 "이영희" 행으로 말할 수 있게 되는 것 — 이것이 pandas의 존재 이유입니다.

숨은 동작: 인덱스 정렬(alignment)

라벨의 대가로 pandas에는 NumPy에 없는 동작이 항상 따라붙습니다. 두 Series를 연산하면 위치가 아니라 인덱스 라벨이 같은 것끼리 계산됩니다.

1
2
3
4
5
6
7
8
midterm = pd.Series([88, 92, 75], index=["김철수", "이영희", "박민수"])
final   = pd.Series([90, 80, 85], index=["이영희", "박민수", "정수진"])

midterm + final
# 김철수      NaN     ← 기말 명단에 없음 (수강 철회)
# 박민수    155.0     ← 75 + 80
# 이영희    182.0     ← 92 + 90
# 정수진      NaN     ← 중간 명단에 없음 (수강 정정으로 추가)

중간·기말 명단의 순서가 달라도 이름만 맞으면 올바르게 합산된다는 점에서 강력한 기능이지만, 모르고 있으면 “왜 갑자기 NaN이 쏟아지지?”의 원인이 됩니다. 서로 다른 곳에서 온 두 데이터를 연산했는데 NaN이 보인다면 십중팔구 인덱스가 어긋난 것 — 위처럼 수강 철회·정정으로 명단이 달라진 경우입니다.

인덱스는 올릴 수도 내릴 수도 있다 — set_index, reset_index

인덱스가 파일을 읽을 때 자동으로 붙는 0, 1, 2…뿐이라면 라벨의 힘을 쓰지 못하는 것입니다. 의미 있는 컬럼을 set_index로 인덱스로 승격시키면 라벨 접근이 강력해집니다. 특히 datetime 인덱스는 부분 문자열 슬라이싱이 열립니다.

1
2
3
4
ts = df.set_index("submitted_at")     # 과제 제출 로그의 제출 시각을 인덱스로

ts.loc["2024-06-15"]                  # 그 날 하루치 행 전부
ts.loc["2024-06-15":"2024-06-21"]     # 한 주치 슬라이스

조건식으로 범위를 표현할 필요 없이 날짜 문자열만으로 자를 수 있습니다. 시계열 데이터를 다룰 때 pandas가 진가를 발휘하는 지점입니다.

반대 방향이 reset_index() — 인덱스를 일반 컬럼으로 되돌립니다. 5편에서 보게 될 groupby 결과는 그룹 키가 인덱스로 오기 때문에, 평평한 표로 저장하거나 이어서 가공하려면 reset_index가 마무리 관용구처럼 붙습니다.

오가는 기준은 이렇게 잡으면 됩니다. 라벨로 접근·슬라이스·정합할 때는 인덱스로 올리고, 저장·merge·일반 가공할 때는 컬럼으로 내린다.

데이터 읽기 — CSV와 Parquet

실무에서 DataFrame을 직접 만들 일은 드물고, 대부분 파일에서 읽습니다.

1
2
df = pd.read_csv("students.csv")
df = pd.read_parquet("grades_2024-1.parquet")

read_csv에서 알아야 할 옵션들

CSV는 사방에서 만나게 되는 형식이지만, 타입 정보가 없는 텍스트라는 근본 한계가 있습니다. pandas가 값을 보고 타입을 추측하는데, 이 추측이 어긋나는 지점마다 옵션이 필요합니다.

1
2
3
4
5
6
df = pd.read_csv(
    "submissions.csv",
    usecols=["submitted_at", "student_id", "score"],  # 필요한 컬럼만 — 메모리 절약
    dtype={"student_id": "int32"},                    # 타입을 직접 지정
    parse_dates=["submitted_at"],                     # 문자열을 datetime으로
)
  • usecols — 컬럼이 수십 개인 파일에서 서너 개만 필요할 때. 읽는 시점에 거르는 것이 읽고 나서 버리는 것보다 훨씬 쌉니다.
  • dtype — 추측에 맡기지 않고 계약을 명시합니다. “0으로 시작하는 우편번호”가 정수로 읽혀 앞자리가 사라지는 사고를 막는 방법이기도 합니다(문자열로 지정).
  • parse_dates — 날짜 컬럼은 지정하지 않으면 그냥 문자열로 읽힙니다. datetime이어야 4편에서 다룰 .dt 연산이 가능해집니다.

자동 결측치 변환이라는 함정

read_csv"N/A", "NA", "null" 같은 문자열을 자동으로 결측치(NaN)로 바꿉니다. 보통은 고마운 기능인데, 데이터가 이 가정을 벗어나면 조용히 틀립니다.

성적 데이터가 정확히 이 함정 위에 있습니다. 학사 시스템에서 Pass/Fail 과목이나 미응시를 "NA"(Not Applicable)라는 정식 등급으로 기록하는 경우가 있는데, 기본 설정으로 읽으면 이 등급이 통째로 NaN이 되어 등급 분포에서 조용히 사라지고 isin 필터도 그대로 빠져나갑니다. 유명한 실화로는 나미비아가 있습니다 — 국가 코드가 문자 그대로 "NA"라서, 국가별 데이터를 읽을 때마다 나라 하나가 결측치로 증발합니다.

1
2
# keep_default_na=False: "NA" 문자열을 NaN으로 바꾸지 않고 그대로 읽는다
grades = pd.read_csv("grades.csv", keep_default_na=False)

에러가 나지 않고 결과만 달라지는 종류의 사고라서, 이런 문제는 코드가 아니라 점검 습관으로 잡아야 합니다. 바로 다음 절의 내용입니다.

Parquet — 분석용 데이터의 표준

CSV와 달리 Parquet은 타입 정보를 파일에 내장한 이진 컬럼형 포맷입니다.

 CSVParquet
dtype없음 — 읽을 때마다 추측파일에 저장됨 — 그대로 복원
용량크다압축으로 수 배 작다
컬럼 선택 읽기전체를 읽고 버려야 함필요한 컬럼만 디스크에서 읽음
사람이 열어보기가능불가능 (도구 필요)

컬럼이 수십 개인 수백 MB짜리 로그 파일에서 두세 개만 필요한 상황은 흔합니다. Parquet은 그 컬럼만 디스크에서 읽으므로 읽기 시간과 메모리가 함께 줄어듭니다.

1
df = pd.read_parquet(path, columns=["student_id", "score"])

정리하면 — 외부와 주고받을 때는 CSV, 내 파이프라인 안에서는 Parquet이 일반적인 선택입니다. 중간 산출물을 CSV로 저장하면 dtype과 datetime이 매번 리셋되는 비용을 치르게 됩니다.

데이터를 받으면 무조건 실행하는 다섯 줄

어떤 데이터든 읽자마자 아래 다섯 줄부터 실행하는 습관을 들여야 합니다. 분석을 시작하기 전에 “이 데이터가 내 가정과 맞는가”를 확인하는 단계입니다.

1
2
3
4
5
df.head()                            # 앞 5행 — 값이 실제로 어떻게 생겼는지
df.info()                            # 컬럼별 dtype, 결측 아닌 개수, 메모리
df.describe()                        # 수치 컬럼의 min/max/평균/사분위수
df.isna().sum()             # 컬럼별 결측치 개수
df["grade"].value_counts()  # 범주형 컬럼(등급)의 값 분포

각각이 잡아내는 문제가 다릅니다.

명령잡아내는 것
head()헤더가 데이터로 밀렸거나, 인코딩이 깨졌거나, 단위가 예상과 다른 경우
info()숫자여야 할 컬럼이 object(문자열)로 읽힌 경우 — 1편에서 본 타입 오염
describe()음수 요금, 말이 안 되는 날짜처럼 범위를 벗어난 값의 존재
isna().sum()결측치 규모 — 처리 전략(6편)을 정하는 근거
value_counts()오타 범주(“N/A”와 “NA”가 따로 존재), 극단적 불균형

이 다섯 줄이 실제로 잡는 것들

이 루틴은 이론이 아니라 실제로 문제를 잡습니다. 공개 데이터든 사내 데이터든 몇 번만 돌려보면 비슷한 패턴을 만나게 됩니다.

describe() — 100점 만점인 시험인데 점수의 min이 -1, max가 999로 나오는 식입니다. -1은 미응시, 999는 채점 보류를 표시하는 센티널 값일 가능성이 높습니다. 어느 쪽이든 “이 파일에는 내 가정 밖의 행이 있다”는 확정 증거이므로, 정제 단계에 범위 필터가 들어가야 합니다.

1
2
valid_score = df["score"].between(0, 100)
df = df.loc[valid_score]

value_counts() — 등급 컬럼을 세어 보면 A/B/C 옆에 "P", "NA", "청강" 같은 특수 등급의 존재가 드러납니다. 학사 규정 문서를 읽지 않았어도 데이터가 스스로 “나를 따로 처리해야 한다”고 말해주는 셈입니다.

head()의 사각지대는 sample()로 — 한 가지 보완할 습관이 있습니다. head()는 파일의 맨 앞 5행만 보여주는데, 데이터가 학번순이나 학과순으로 정렬되어 있으면 앞부분은 전체를 대표하지 못합니다. 맨 앞 5행이 전부 같은 학과, 같은 학년일 수 있으니까요. 무작위로 뽑아 보는 sample을 함께 쓰면 이 편향이 사라집니다.

1
df.sample(5, random_state=42)   # 무작위 5행 — 시드를 주면 재현 가능

앞부분은 멀쩡한데 뒷부분만 깨진 파일(중간부터 인코딩이 바뀌거나, 특정 월부터 컬럼 의미가 달라지는 경우)은 head만으로는 절대 안 보입니다. head로 구조를, sample로 대표성을 확인한다고 역할을 나누면 됩니다.

info() — 수백만 행 데이터의 메모리 사용량이 표시되므로, 1편에서 다룬 dtype 다운캐스트가 필요한지 여기서 판단합니다.

반복되는 문자열은 category로

메모리 이야기가 나온 김에, 문자열 컬럼의 최적화 수단 하나를 소개합니다. 문자열은 DataFrame 메모리의 주범인데, 그 문자열이 “컴퓨터공학과”, “경영학과”처럼 고유값 몇 개가 수십만 행에 반복되는 형태라면 크게 줄일 수 있습니다. 매 행마다 문자열을 저장하는 대신 정수 코드 + 대응표로 바꾸는 것 — category dtype입니다.

1
2
3
df["department"].memory_usage(deep=True)      # 문자열 그대로: 수백 MB
df["department"] = df["department"].astype("category")
df["department"].memory_usage(deep=True)      # 수 MB — 행마다 1~2바이트 코드만 남는다

판단 기준은 nunique()입니다. 고유값 수가 행 수에 비해 훨씬 적을 때만 이득입니다. 학과(수십 개), 등급(십여 개), 개설 과목(수백 개) 같은 컬럼이 이상적인 후보입니다. 반대로 거의 모든 행이 다른 값인 컬럼(학번, 자유 텍스트)은 대응표가 데이터만큼 커져서 오히려 손해입니다.

부수 효과로 groupby나 비교 연산도 문자열 대신 정수 코드로 처리되어 빨라집니다. 첫 점검에서 value_counts()를 봤을 때 “고유값 몇 개짜리 문자열 컬럼”이 보이면 category 전환을 떠올리면 됩니다.

점검 루틴의 핵심은 “문제가 있을 것 같아서”가 아니라 “문제가 없음을 확인하기 전까지는 데이터를 믿지 않는다”는 태도입니다. 에러를 내는 문제는 어차피 잡힙니다. 무서운 것은 조용히 틀린 결과를 내는 문제이고, 그것은 이 다섯 줄에서만 보입니다.

저장하기

읽기의 반대는 간단하지만, 관용구 하나는 기억할 필요가 있습니다.

1
2
df.to_parquet("grades_clean.parquet", index=False)
df.to_csv("report.csv", index=False)

index=False를 빼면 행 인덱스(0, 1, 2, …)가 파일에 함께 저장되고, 다시 읽을 때 Unnamed: 0이라는 유령 컬럼으로 나타납니다. 인덱스에 의미 있는 값을 넣지 않았다면 저장할 때 버리는 것이 깔끔합니다.

정리

개념한 줄 요약
Series / DataFrame배열 + 라벨 / 그 Series들의 묶음. .values로 NumPy 복귀
인덱스 정렬연산은 위치가 아니라 라벨끼리. 갑작스런 NaN의 주범
set_index / reset_index라벨로 다룰 땐 올리고, 저장·merge할 땐 내린다
read_csv 옵션usecols, dtype, parse_dates — 추측 대신 계약
category고유값 적은 문자열 컬럼의 메모리를 수십 배 절약
자동 NA 변환"N/A" 문자열이 NaN이 된다. 필요시 keep_default_na=False
Parquet타입 보존 + 압축 + 컬럼 선택 읽기. 파이프라인 내부의 표준
첫 점검 5줄head / info / describe / isna().sum() / value_counts()

데이터가 어떻게 생겼는지 파악했으니, 다음 편에서는 그 안에서 원하는 행과 열을 정확히 골라내고 바꾸는 법 — loc/iloc, boolean mask, 그리고 .dt/.str 접근자를 다룹니다.

다음 글: NumPy, Pandas 기초 (4) — 원하는 데이터만 골라 바꾸기

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.