NumPy, Pandas 기초 (4) — 원하는 데이터만 골라 바꾸기: loc·iloc·마스크·접근자
loc과 iloc의 차이, boolean mask로 행을 거르는 표준 패턴, 선택과 대입을 한 번에 하는 이유, datetime과 문자열을 벡터화로 다루는 .dt·.str 접근자, 그리고 apply를 최후의 수단으로 미뤄야 하는 이유를 정리했습니다.
NumPy와 pandas를 기초부터 정리하는 6편 시리즈의 네 번째 글입니다. 2편의 boolean 인덱싱과 뷰/복사 개념, 3편의 DataFrame 구조를 전제로 합니다.
loc과 iloc — 이름으로 고를 것인가, 순서로 고를 것인가
pandas에서 행을 고르는 공식 통로는 두 개입니다.
.loc[행, 열]— 라벨(이름) 기준.iloc[행, 열]— 정수 위치(순서) 기준
1
2
3
4
5
6
7
8
9
10
11
12
df = pd.DataFrame(
{"course": ["수학", "영어", "물리"], "score": [92, 85, 77]},
index=["a", "b", "c"],
)
df.loc["b"] # 라벨이 "b"인 행
df.loc["a", "score"] # 행 "a"의 score 값
df.loc[:, "course"] # course 열 전체
df.iloc[1] # 두 번째 행 (0부터 셈)
df.iloc[0, 1] # 첫 행, 두 번째 열
df.iloc[-1] # 마지막 행
둘을 구분해야 하는 이유는 인덱스가 정수일 때 극명해집니다. 행을 몇 개 걸러낸 DataFrame은 인덱스가 [0, 3, 7, ...]처럼 듬성듬성해지는데, 이때 df.loc[3]은 “라벨이 3인 행”이고 df.iloc[3]은 “네 번째 행”입니다. 서로 다른 행입니다.
슬라이스 동작도 다릅니다. 헷갈리기 쉬운 지점이라 표로 박아둡니다.
df.loc["a":"c"] | df.iloc[0:2] | |
|---|---|---|
| 기준 | 라벨 | 위치 |
| 끝점 | 포함 (“c”까지) | 미포함 (Python 관례) |
“라벨 슬라이스는 끝을 포함한다”는 pandas만의 예외입니다. 이름으로 자를 때 “c까지 달라”고 했는데 c 직전에서 끊기면 직관에 어긋나기 때문에 내린 설계 결정입니다.
boolean mask — 행을 거르는 표준 패턴
실무에서 가장 많이 쓰는 것은 라벨도 위치도 아니고 조건입니다. 2편에서 본 boolean 인덱싱이 그대로 pandas로 넘어옵니다. 조건식이 True/False Series(마스크)를 만들고, .loc[마스크]가 True인 행만 남깁니다.
과제 제출 기록을 정제하는 전형적인 흐름은 이렇습니다.
1
2
3
4
5
6
7
# 이번 학기 범위 안의 레코드만
in_semester = df["submitted_at"].between(term_start, term_end, inclusive="left")
df = df.loc[in_semester]
# 개설 중인 과목의 레코드만
open_course = df["course_id"].isin(open_course_ids)
df = df.loc[open_course]
자주 쓰는 조건 도구들:
1
2
3
4
5
df["score"] >= 90 # 비교 연산
df["course_id"].isin([101, 205, 330]) # 목록에 포함되는가
df["score"].between(0, 100) # 범위 안인가 (양끝 포함이 기본)
df["course_name"].str.contains("개론") # 문자열 포함
df["submitted_at"].dt.hour < 6 # 시간 조건 (아래 접근자 참고)
조건 결합 규칙은 NumPy와 동일합니다 — &, |, ~에 조건마다 괄호. and/or는 에러가 납니다.
1
night_owl = df.loc[(df["submitted_at"].dt.hour >= 0) & (df["submitted_at"].dt.hour < 6)]
조건이 길어져 괄호가 번잡해지면 query라는 대안도 있습니다. 조건식을 문자열로 쓰는 방식인데, and/or를 그대로 쓸 수 있고 컬럼 이름을 반복하지 않아 읽기 편합니다.
1
2
df.query("60 <= score < 90 and department == '컴퓨터공학과'")
df.query("course_id in @open_course_ids") # @로 외부 변수 참조
다만 값을 대입하는 용도로는 못 쓰고(읽기 전용), 컬럼 이름에 공백이 있으면 백틱이 필요해지는 등 마스크 방식보다 제약이 있습니다. 읽기 전용 필터가 길어질 때의 가독성 도구 정도로 자리를 잡아주고, 기본기는 마스크 + .loc으로 다지는 것을 권합니다.
마스크를 변수에 담아 이름을 붙이는 습관도 권합니다. in_semester, open_course처럼 이름이 붙은 마스크는 조건의 의도를 코드로 문서화하고, 위의 정제 코드처럼 “몇 건이 걸러졌는지” 로그를 남길 때도 재사용됩니다.
1
dropped = int((~open_course).sum()) # 마스크 부정의 합 = 걸러진 행 수
선택과 대입은 한 번에 — chained assignment 금지
값을 읽을 때는 df[df["count"] > 100]처럼 대괄호를 겹쳐도 결과는 같습니다. 문제는 값을 쓸 때입니다.
1
2
df[df["score"] >= 90]["honor"] = True # 안 된다
df.loc[df["score"] >= 90, "honor"] = True # 올바른 방법
첫 줄이 왜 안 되는지는 2편의 뷰/복사 규칙으로 설명됩니다. df[마스크]는 boolean 인덱싱이므로 복사본을 만들고, 이어지는 ["flag"] = True는 그 복사본에 값을 씁니다. 원본 df는 그대로입니다. 대괄호를 연달아 쓴다고 해서(chained) 원본까지 이어지지 않습니다.
.loc[행 조건, 열] 형태는 선택과 대입이 하나의 연산이라 이 문제가 원천적으로 없습니다. 예전 pandas는 이런 코드에 SettingWithCopyWarning이라는 애매한 경고를 냈고 어떨 땐 되고 어떨 땐 안 되는 상태였는데, Copy-on-Write 도입 이후 “항상 안 되는 것”으로 통일되고 있습니다(6편에서 정리). 처음부터 .loc 한 번으로 쓰는 습관을 들이면 이 역사를 몰라도 됩니다.
새 컬럼 만들기 — 벡터화 연산으로
기존 컬럼을 조합해 새 컬럼을 만드는 것은 대입 한 줄입니다. 우변이 벡터화 연산이면 전체 행에 한 번에 적용됩니다.
1
2
3
df["total"] = df["midterm"] * 0.4 + df["final"] * 0.6 # 가중 합산 점수
df["is_weekend"] = df["submitted_at"].dt.dayofweek >= 5
df["score_ratio"] = (df["score"] / df["max_score"]).round(3)
조건에 따라 다른 값을 넣을 때는 2편에서 본 np.where가 그대로 쓰입니다.
1
df["result"] = np.where(df["total"] >= 60, "pass", "fail")
.dt와 .str — 벡터화의 사각지대를 메우는 접근자
숫자는 df["a"] * 2처럼 바로 벡터화되지만, 날짜와 문자열은 곱하기로 다룰 수 없습니다. pandas는 이를 위해 전용 통로를 제공합니다 — datetime 컬럼에는 .dt, 문자열 컬럼에는 .str을 붙이면 해당 타입의 연산이 전부 벡터화로 열립니다.
1
2
3
4
5
# datetime 컬럼에서
df["submitted_at"].dt.hour # 시각 (0~23)
df["submitted_at"].dt.dayofweek # 요일 (월=0 ... 일=6)
df["submitted_at"].dt.date # 날짜 부분만
df["submitted_at"].dt.floor("h") # 1시간 단위로 내림 — 시간대별 집계의 핵심
dt.floor("h")는 5편의 groupby에서 주인공이 됩니다. 08:47:23을 08:00:00으로 내려서 “같은 시간대”라는 그룹 키를 만드는 도구입니다.
1
2
3
4
5
# 문자열 컬럼에서
df["email"].str.lower() # 소문자화
df["course_name"].str.contains("실험") # 포함 여부 → boolean mask로 사용
df["name"].str.strip() # 앞뒤 공백 제거
df["course_code"].str.split("-").str[0] # "CS-101" → "CS" — 학과 코드 추출
.str.strip()은 지루해 보이지만 실무 사용 빈도가 높습니다. 사람 손을 탄 데이터에는 "김철수 "처럼 보이지 않는 공백이 붙어 있는 일이 흔하고, 이것이 value_counts()에서 같은 학생이 두 명으로 나뉘는 원인이기 때문입니다.
.dt가 에러를 낸다면 컬럼이 datetime이 아니라 문자열이라는 뜻입니다. 3편의parse_dates로 읽었는지, 아니면pd.to_datetime(df["submitted_at"])으로 변환했는지 확인하세요. 이 에러는 사실 “타입 점검을 건너뛰었다”는 알림입니다.
apply는 최후의 수단
벡터화 연산으로 표현이 안 되는 로직은 apply로 임의의 Python 함수를 행마다 적용할 수 있습니다.
1
df["custom"] = df["score"].apply(lambda x: complicated_logic(x))
동작은 하지만, apply는 겉만 pandas일 뿐 속은 Python 반복문입니다. 1편에서 본 “원소마다 인터프리터 개입” 비용을 그대로 치릅니다. 수백만 행에서 벡터화 연산과 수십~수백 배 차이가 나는 것도 동일합니다.
그래서 우선순위를 이렇게 잡습니다.
- 벡터화 연산 — 산술, 비교,
np.where,.dt/.str로 표현되는가? map— 값을 다른 값으로 바꾸는 단순 대응이라면 딕셔너리 매핑으로.1
df["grade_point"] = df["grade"].map({"A": 4.0, "B": 3.0, "C": 2.0}) # 등급 → 평점
apply— 위 둘로 안 되는 진짜 복잡한 로직만. 그리고 이때도 “이 로직을 벡터화로 못 쪼개나?”를 한 번 더 의심합니다.
경험상 apply를 쓰고 싶어지는 로직의 대부분은 np.where의 중첩이나 np.select(조건 여러 개 버전)로 풀립니다.
1
2
3
4
5
df["grade"] = np.select(
[df["total"] >= 90, df["total"] >= 80],
["A", "B"],
default="C",
)
값 교정 도구 — replace와 clip
고르고 만드는 것 외에, 이미 있는 값을 고치는 전용 도구들이 있습니다.
replace는 값 대 값 치환입니다. 오타 범주를 통일하거나, 특수 코드를 결측으로 되돌릴 때 씁니다.
1
2
df["department"] = df["department"].replace({"컴공": "컴퓨터공학과", "컴퓨터공학": "컴퓨터공학과"})
df["score"] = df["score"].replace(-1, np.nan) # 미응시 센티널을 결측으로
두 번째 줄이 의외로 자주 필요합니다. -1, 999 같은 매직 넘버로 미응시·채점 보류를 표시하는 시스템이 아직 많고, 이 값을 그대로 두면 평균이 통째로 왜곡됩니다. 3편의 describe()에서 min이 -1로 나온다면 바로 이 처리가 필요하다는 신호입니다.
clip은 범위 밖 값을 경계값으로 자릅니다. 1편에서 NumPy 버전을 이미 봤고, pandas에서도 같습니다.
1
df["score"] = df["score"].clip(0, 100) # 0~100 범위 강제
주의할 점 — clip은 값을 바꾸는 결정입니다. 가산점으로 104점이 된 학생을 100점으로 자르는 것(만점 상한 정책)과, 104점을 입력 오류로 보고 그 행을 제거하는 것은 다른 분석입니다. 이상값이 오류라면 제거가, “극단적이지만 실제 값”이라면 clip이 어울립니다. 문법이 아니라 판단의 문제이고, 6편의 결측치 논의와 같은 구조입니다.
연속값을 구간으로 — cut과 qcut
수치를 범주로 바꾸는 도구입니다. 점수를 학점으로 바꾸는 것이 정확히 이 작업입니다.
1
2
3
4
5
6
7
8
9
# cut — 내가 정한 경계로 자르기 (절대평가)
df["grade"] = pd.cut(
df["total"],
bins=[0, 60, 70, 80, 90, 100],
labels=["F", "D", "C", "B", "A"],
)
# qcut — 개수가 균등해지도록 분위수로 자르기 (상대평가)
df["quartile"] = pd.qcut(df["total"], 4, labels=["하위", "중하", "중상", "상위"])
구분 기준을 성적으로 기억하면 잊히지 않습니다. 정해진 점수 경계로 자르는 절대평가가 cut, 등수 기준으로 등분하는 상대평가가 qcut. cut은 구간별 인원이 크게 불균등할 수 있고(A가 한 명도 없을 수 있음), qcut은 반대로 경계 점수가 데이터에 따라 달라집니다(올해의 A 커트라인은 작년과 다름). 결과는 3편에서 본 category dtype으로 나오므로, 그대로 groupby 키로 쓸 수 있습니다 — 다음 편의 재료입니다.
정렬, 이름 바꾸기, 버리기
마무리로 자주 쓰는 정리 도구들입니다.
1
2
3
4
5
df.sort_values("score", ascending=False) # 값 기준 정렬
df.sort_values(["department", "student_id"]) # 다중 키 정렬
df.rename(columns={"stu_id": "student_id"}) # 컬럼명 변경
df.drop(columns=["memo"]) # 컬럼 삭제
df.drop_duplicates(subset=["student_id", "course_id"]) # 중복 행 제거 — 재수강 기록 등
공통 주의점 하나 — 이들은 기본적으로 수정된 복사본을 반환하며 원본을 바꾸지 않습니다. df.sort_values(...)만 쓰고 결과를 받지 않으면 아무 일도 일어나지 않은 것입니다. df = df.sort_values(...)처럼 다시 대입하는 것이 표준 스타일입니다(inplace=True 옵션은 이점이 없어 커뮤니티에서 비권장으로 굳어졌습니다).
정리
| 개념 | 한 줄 요약 |
|---|---|
.loc / .iloc | 라벨 기준 / 위치 기준. 라벨 슬라이스만 끝점 포함 |
| boolean mask | 조건으로 행 거르기. & \| ~ + 괄호, 마스크에 이름 붙이기 |
| 선택+대입 | .loc[조건, 열] = 값 한 번에. 대괄호 체인은 복사본에 쓴다 |
.dt / .str | 날짜·문자열 연산의 벡터화 통로. floor("h"), contains |
| apply | 속은 Python 반복문. 벡터화 → map → apply 순으로 |
| replace / clip | 값 치환 / 범위 강제. clip은 값을 바꾸는 “결정”임을 의식 |
| cut / qcut | 절대평가(점수 경계)는 cut, 상대평가(등수 등분)는 qcut |
| 정렬·삭제 | 복사본 반환이 기본. 재대입하는 스타일로 통일 |
행과 열을 자유자재로 다루게 됐으니, 다음 편은 pandas의 하이라이트 — 데이터를 그룹으로 쪼개 집계하고(groupby), 있어야 할 모양을 강제하고(reindex), 흩어진 테이블을 붙이는(merge) 이야기입니다.