NumPy, Pandas 기초 (6) — 조용히 틀리는 것들: 결측치·dtype 함정·버전 관리
에러 없이 결과만 틀리는 사고들 — 결측치 처리의 판단 기준, NaN이 일으키는 dtype 승격, 뷰·복사와 Copy-on-Write, 그리고 계속 업데이트되는 NumPy·pandas와 함께 사는 법으로 시리즈를 마무리합니다.
NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 마지막 글입니다. 앞의 다섯 편이 “어떻게 하는가”였다면, 이번 편은 “어떻게 틀리는가”입니다.
가장 무서운 버그는 에러를 내지 않는다
프로그램이 죽는 버그는 사실 친절한 버그입니다. 위치를 알려주고, 고치기 전까지 진행을 막아주니까요. 데이터 작업에서 정말 무서운 것은 끝까지 실행되고 그럴듯한 결과를 내는데 그 결과가 틀린 경우입니다. 평균이 미묘하게 어긋나고, 행이 몇 천 개 증발하고, 0이어야 할 곳이 NaN인 채로 모델 학습까지 흘러갑니다.
이 시리즈에서 지나온 함정들은 전부 이 유형입니다. 마지막 편에서 하나씩 정면으로 다룹니다.
결측치 — 문법보다 판단이 어렵다
결측치를 다루는 문법 자체는 사실상 두 개뿐입니다.
1
2
3
df.dropna(subset=["score"]) # 점수가 결측인 행 제거
df["bonus"].fillna(0) # 특정 값으로 채우기
df["score"].fillna(df["score"].median()) # 통계값으로 채우기
어려운 것은 문법이 아니라 무엇으로 채울지의 결정이고, 이것은 항상 같은 질문에서 시작합니다 — “이 값은 왜 비어 있는가?”
| 결측의 원인 | 올바른 처리 |
|---|---|
| 실제로 없었던 것 (가산점 없음, 제출 0건) | 0으로 채우기 |
| 값은 존재했는데 기록이 누락 | 평균/중앙값 등으로 추정 (이상치가 있으면 중앙값이 안전) |
| 특정 조건에서만 체계적으로 누락 | 함부로 채우면 편향 — 원인 조사가 먼저 |
| 소수이고 무작위 발생 | 행 제거 허용 |
같은 fillna(0)이라도 5편의 reindex(fill_value=0)에서는 정답이었습니다 — 제출 기록이 없는 시간대는 실제로 0건이었으니까요. 하지만 점수 컬럼의 결측을 0으로 채우는 것은 전혀 다른 문제입니다. 미제출은 0점 처리한다는 규정이 있다면 정답이고, 채점 누락이라면 학생에게 억울한 0점을 주는 데이터 조작입니다. 문법은 같고 판단만 다릅니다 — 그리고 그 판단의 근거는 코드가 아니라 학사 규정(도메인 지식)에 있습니다. 결측치 처리 코드에 “왜 이 값으로 채우는가”를 주석으로 남기는 것이 자신을 위한 문서화입니다.
주의점 하나 — 결측 비교는 == np.nan으로 하면 안 됩니다. NaN은 자기 자신과도 같지 않다고 정의되어 있어서(np.nan == np.nan은 False), 반드시 isna()/notna()를 써야 합니다.
시계열의 결측은 채우는 방향이 있다
시계열 데이터에는 통계값 대신 시간 순서를 이용하는 채우기가 따로 있습니다.
1
2
3
ts["total_study_hours"].ffill() # 앞(과거) 값을 끌어와 채움
ts["total_study_hours"].interpolate() # 앞뒤 값을 선형 보간
ts["total_study_hours"].bfill() # 뒤(미래) 값을 끌어와 채움 — 주의
학습관리시스템(LMS)의 일별 누적 학습 시간처럼 “기록이 잠깐 끊겨도 값 자체는 이어진다”는 성질이 있으면 ffill이나 interpolate가 자연스럽습니다. 시스템 점검일에 기록이 없다고 누적치가 0이 되는 것은 아니니까요.
하지만 예측 문제에서 bfill은 위험합니다. 학기 중반의 학습 데이터로 기말 성적을 예측하는 모델을 만든다면, 미래의 값으로 과거의 구멍을 채우는 순간 학습 시점에는 알 수 없었던 정보가 피처에 스며듭니다. 이것이 데이터 누출(leakage)의 전형적인 입구입니다 — 검증 점수는 훌륭한데 실전에서 무너지는 모델의 흔한 원인이기도 합니다. 시계열 예측 파이프라인의 원칙은 하나입니다. 채우기는 항상 과거 방향으로.
NaN이 정수 컬럼을 float로 바꾼다
시리즈 내내 예고했던 함정입니다. NumPy의 int64에는 NaN을 표현할 방법이 없습니다. NaN은 부동소수점 표준(IEEE 754)에 정의된 float의 특수값이기 때문입니다. 그래서 정수 컬럼에 결측이 하나라도 생기면 pandas는 컬럼 전체를 float64로 승격시킵니다.
이 현상이 나타나는 대표적인 순간이 5편의 left merge입니다.
1
2
enrollments = enrollments.merge(courses, on="course_id", how="left")
enrollments["credits"] # 어라, 3.0 — 정수였는데?
정수여야 할 학점 수가 3.0으로 보인다면 십중팔구 어딘가에 NaN이 생겼다는 뜻입니다 — 과목 마스터에 없는 폐강 과목의 수강 기록이 merge에서 매칭에 실패했든, reindex의 빈칸이든. 그래서 이 현상은 성가신 버그이자 동시에 데이터 문제의 탐지기입니다. dtype이 예고 없이 float로 변했다면 그 컬럼에 isna().sum()부터 돌려보면 됩니다.
정수형을 유지하면서 결측을 담아야 한다면 pandas의 nullable 정수 타입이 있습니다.
1
df["credits"] = df["credits"].astype("Int64") # 대문자 I — NaN 대신 <NA> 허용
int64(NumPy)와 Int64(pandas nullable)는 대소문자 하나 차이지만 다른 타입입니다. 결측 가능한 정수 컬럼이라는 게 명확하다면 nullable 타입이 의도를 정직하게 표현합니다.
0으로 나누면 에러가 아니라 inf가 나온다
Python의 1 / 0은 에러를 내지만, NumPy와 pandas의 배열 나눗셈은 경고만 내고 계속 진행합니다.
1
2
3
4
total_score = pd.Series([270.0, 180.0, 90.0])
n_submitted = pd.Series([3.0, 0.0, 0.0])
total_score / n_submitted # [90.0, inf, inf] — 에러 없음, 실행은 계속된다
“과제 평균 점수 = 총점 / 제출 수” 같은 나눗셈 파생 컬럼이 바로 이 위험 지점입니다. 제출 수가 0인 학생(수강 철회, 장기 결석)이 하나라도 있으면 inf가 조용히 섞이고, 이후의 평균은 inf가 되고 표준화는 NaN 범벅이 됩니다.
더 고약한 점은 isna()가 inf를 결측으로 세지 않는다는 것입니다. 결측 점검을 통과한 깨끗해 보이는 데이터에 inf가 숨어 있을 수 있습니다. 방어는 두 방향입니다.
1
2
3
4
5
6
# 사전: 분모가 0인 행을 먼저 분리
valid = df["n_submitted"] > 0
df.loc[valid, "avg_score"] = df["total_score"] / df["n_submitted"]
# 사후: inf를 결측으로 강등시켜 기존 결측 처리에 합류시킴
df["avg_score"] = df["avg_score"].replace([np.inf, -np.inf], np.nan)
describe()에서 max가 inf로 보인다면 이미 일어난 뒤입니다. 나눗셈으로 컬럼을 만들 때마다 “분모가 0인 행이 있는가”를 먼저 묻는 습관이 최선의 방어입니다.
뷰, 복사, 그리고 Copy-on-Write — 4편의 마무리
2편과 4편에서 나눠 다룬 뷰/복사 문제를 한 번에 정리합니다.
- NumPy: 슬라이스는 뷰(원본 공유), boolean·fancy 인덱싱은 복사
- pandas의
df[마스크]는 boolean 인덱싱 → 복사본 - 따라서
df[마스크]["col"] = 값은 복사본에 쓰는 것 → 원본 불변 (chained assignment) - 올바른 방법:
df.loc[마스크, "col"] = 값— 선택과 대입이 한 연산
역사적으로 pandas는 이 지점이 애매했습니다. 상황에 따라 뷰가 나오기도 복사가 나오기도 해서, SettingWithCopyWarning이라는 “될 수도 안 될 수도 있다”는 경고로 사용자에게 판단을 떠넘겼습니다. Copy-on-Write(CoW)는 이 애매함을 끝내는 설계 변경입니다.
CoW 아래에서는 규칙이 하나로 통일됩니다 — 모든 파생 DataFrame은 논리적으로 독립된 복사본처럼 동작한다. 실제 메모리 복사는 쓰기가 일어나는 순간까지 미루므로(이름의 유래) 성능 손해도 없습니다. chained assignment는 “어떨 땐 되는 것”에서 “항상 안 되는 것”으로 확정되었고, pandas 3.0에서 기본 동작이 됩니다.
실용적 결론은 4편과 같습니다. 읽을 때든 쓸 때든 .loc 한 번으로 끝내는 습관이면 이 역사를 몰라도 안전합니다.
조용한 사고 체크리스트 — 시리즈 총정리
이 시리즈에서 다룬 “에러 없이 틀리는” 지점들을 한 표로 모읍니다. 결과가 이상할 때 위에서부터 훑는 용도입니다.
| 증상 | 원인 | 나온 편 |
|---|---|---|
숫자 컬럼이 object로 읽힘 | 컬럼 어딘가 문자 혼입 → 전체 타입 승격 | 1편 |
| 다운캐스트 후 값이 이상함 | astype overflow — 범위 확인 없이 내림 | 1편 |
| 원본 배열이 멋대로 바뀜 | 슬라이스가 뷰 — .copy() 누락 | 2편 |
| 연산 후 NaN이 쏟아짐 | 인덱스 정렬 — 라벨이 어긋난 데이터끼리 연산 | 3편 |
| 필터가 특정 값을 놓침 | "N/A" 문자열의 자동 NaN 변환 | 3편 |
| 대입했는데 원본 그대로 | chained assignment — 복사본에 씀 | 4편 |
| 건수가 미묘하게 작음 | count()는 NaN 제외 — size()를 써야 할 자리 | 5편 |
| merge 후 행 증가 | 참조 테이블 키 중복 (1:N) — validate로 차단 | 5편 |
| merge 후 행 감소 | how 기본값 inner — 매칭 실패 행이 조용히 삭제 | 5편 |
| 정수 컬럼이 갑자기 float | NaN 발생 (merge 실패, reindex 빈칸) | 6편 |
| 평균이 inf, 스케일링이 NaN 범벅 | 0으로 나누기 — inf는 isna()에 안 잡힘 | 6편 |
| 검증 점수만 비정상적으로 좋음 | bfill 등 미래 정보 누출 | 6편 |
공통 방어 습관은 하나로 수렴합니다. 변환 전후의 행 수·dtype·결측 수를 로그로 남기고 눈으로 확인한다. 정제 단계마다 “몇 건 중 몇 건 제거”를 기록하는 코드가 번거로워 보여도, 조용한 사고를 잡아내는 유일한 그물이 바로 그 로그입니다.
계속 업데이트되는 도구와 함께 사는 법
마지막 주제는 코드가 아니라 환경입니다. NumPy와 pandas는 완성된 유물이 아니라 지금도 활발히 개발되는 프로젝트이고, 버전에 따라 동작이 실제로 다릅니다.
- NumPy 2.0 (2024) — 약 18년 만의 메이저 업데이트. 오래된 API가 대거 정리되어, 옛날 튜토리얼 코드가 최신 환경에서 깨지는 주원인이 됐습니다.
- pandas 2.x → 3.0 — 위에서 다룬 Copy-on-Write가 기본이 되고, 문자열 처리 등에서 PyArrow 기반 백엔드가 확대되는 방향으로 진행 중입니다.
둘 다 NumFOCUS라는 비영리 재단이 후원하는 커뮤니티 프로젝트라 특정 회사에 종속되지 않고, 대신 커뮤니티의 페이스로 꾸준히 변합니다. 이 사실에서 실무 습관 세 가지가 나옵니다.
1. 오래된 답변을 의심한다. 검색으로 찾은 Stack Overflow 답변이나 블로그 코드가 지금 버전에서 경고를 내거나 다르게 동작하는 일은 예외가 아니라 일상입니다. 답변의 작성 연도를 확인하는 습관, 그리고 최종 확인은 공식 문서로 하는 습관이 필요합니다.
2. FutureWarning은 미래의 에러 예고편이다. 이 경고는 “지금은 되지만 다음 메이저 버전에서 사라진다”는 뜻입니다. 무시하고 쌓아두면 언젠가 업그레이드하는 날 한꺼번에 터집니다. 보일 때마다 그때그때 고쳐두는 것이 이자 없이 빚을 갚는 방법입니다.
3. 버전을 명시해서 환경을 재현 가능하게 만든다. “내 컴퓨터에서는 됐는데”의 상당수는 버전 차이입니다. pyproject.toml에 범위를 선언하고 lock 파일로 정확한 버전을 고정합니다.
1
2
3
4
dependencies = [
"pandas>=2.2,<3",
"numpy>=2.0,<3",
]
상한(<3)을 두는 이유는 메이저 버전 업데이트가 동작 변경을 동반하기 때문입니다. 3.0으로 올릴 때는 의존성 선언을 바꾸는 의식적인 결정과 함께 테스트를 돌리는 것이지, 어느 날 재설치했더니 몰래 올라가 있는 상황을 만들지 않는 것입니다. 환경 관리 도구 자체가 궁금하다면 venv, conda, uv 정리 글로 이어집니다.
시리즈를 마치며
여섯 편의 내용을 한 문장씩으로 압축하면 이렇습니다.
- 벡터화 — 반복문을 인터프리터 밖으로.
for문이 보이면 의심한다. - 배열 다루기 — 슬라이스는 뷰, axis는 없어지는 차원, 브로드캐스팅은 뒤에서부터.
- 첫 점검 — 문제가 없음을 확인하기 전까지 데이터를 믿지 않는다.
- 선택과 변형 —
.loc한 번으로, apply 대신 벡터화로. - 집계와 결합 — groupby는 있는 것을 세고, reindex는 있어야 할 모양을 강제한다.
- 조용한 사고 — 전후의 행 수·dtype·결측 수를 확인하는 습관이 방어의 전부다.
기초 문법은 여기까지입니다. 다음 스터디 노트에서는 이 위에서 실제 모델의 입력을 만드는 작업 — 시계열 피처 엔지니어링(lag, rolling window)을 다룰 예정입니다. 5편에서 만든 것 같은 (시간 × 항목) 그리드가 그대로 재료가 됩니다.