NumPy, Pandas 기초 (3) — Series와 DataFrame, 그리고 데이터 첫 점검
pandas의 두 축인 Series와 DataFrame의 구조, 인덱스 정렬이라는 숨은 동작, CSV와 Parquet 읽기, 그리고 데이터를 받자마자 실행해야 할 다섯 줄의 점검 루틴을 정리했습니다.
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은 타입 정보를 파일에 내장한 이진 컬럼형 포맷입니다.
| CSV | Parquet | |
|---|---|---|
| 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 접근자를 다룹니다.