Pandas 기초 (3) - 데이터 읽기와 첫 점검
CSV와 Parquet을 읽는 법, read_csv의 필수 옵션과 자동 결측치 변환이라는 함정, 데이터를 받자마자 실행해야 할 다섯 줄의 점검 루틴, 그리고 category dtype과 저장 관용구까지 정리했습니다.
Pandas를 기초부터 정리하는 시리즈의 세 번째 글입니다. 2편에서 본 Series와 DataFrame 구조 위에서, 파일을 읽어 표를 만들고 그 표를 점검하는 단계를 다룹니다.
데이터 읽기: CSV와 Parquet
실무에서 DataFrame을 직접 만들 일은 드물고, 대부분 파일에서 읽습니다.
1
2
3
4
import pandas as pd
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(문자열)로 읽힌 경우. NumPy 기초 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(): 수백만 행 데이터의 메모리 사용량이 표시되므로, NumPy 기초 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이라는 유령 컬럼으로 나타납니다. 인덱스에 의미 있는 값을 넣지 않았다면 저장할 때 버리는 것이 깔끔합니다.
정리
| 개념 | 한 줄 요약 |
|---|---|
| read_csv 옵션 | usecols, dtype, parse_dates: 추측 대신 계약 |
| 자동 NA 변환 | "N/A" 문자열이 NaN이 된다. 필요시 keep_default_na=False |
| Parquet | 타입 보존 + 압축 + 컬럼 선택 읽기. 파이프라인 내부의 표준 |
| 첫 점검 5줄 | head / info / describe / isna().sum() / value_counts() |
| sample | head의 사각지대 보완. 구조는 head로, 대표성은 sample로 |
| category | 고유값 적은 문자열 컬럼의 메모리를 수십 배 절약 |
| 저장 | to_csv / to_parquet에 index=False가 기본 관용구 |
데이터가 어떻게 생겼는지 파악했으니, 다음 편에서는 그 안에서 원하는 행과 열을 정확히 골라내고 바꾸는 법, 즉 loc/iloc, boolean mask, 그리고 .dt/.str 접근자를 다룹니다.