파이썬 데이터 분석 #3 선택과 필터: loc, iloc, 불리언 인덱싱

7 분 소요

데이터를 불러왔다면 다음 할 일은 정해져 있습니다. 전체에서 필요한 부분만 골라내는 일입니다. “이 열만”, “이 조건에 맞는 행만”, “두 가지를 동시에” 같은 선택이 실제 분석 코드의 절반을 차지합니다. 이번 글에서는 pandas의 선택 도구인 열 선택, loc, iloc, 불리언 인덱싱을 차례로 다루고, 누구나 한 번은 만나는 SettingWithCopyWarning까지 정리하겠습니다. #2 불러오기와 탐색에서는 CSV를 읽어 데이터의 생김새를 살펴봤습니다. 이번에는 어디서든 바로 따라 할 수 있도록 작은 DataFrame을 코드로 만들고 시작하겠습니다.

예제 데이터
import pandas as pd

df = pd.DataFrame({
    "name": ["김치찌개", "비빔밥", "불고기", "냉면", "파스타", "피자"],
    "category": ["한식", "한식", "한식", "한식", "양식", "양식"],
    "price": [9000, 10000, 15000, 11000, 14000, 18000],
    "qty": [42, 35, 21, 18, 27, 30],
})
print(df)
#    name category  price  qty
# 0  김치찌개     한식   9000   42
# 1   비빔밥     한식  10000   35
# 2   불고기     한식  15000   21
# 3    냉면     한식  11000   18
# 4   파스타     양식  14000   27
# 5    피자     양식  18000   30

열 선택: 대괄호 하나와 둘의 차이 #

가장 기본은 열 선택입니다. 그런데 대괄호를 하나 쓰는지 둘 쓰는지에 따라 결과의 타입이 달라집니다.

Series vs DataFrame
df["price"]            # Series (1차원)
df[["name", "price"]]  # DataFrame (2차원 표)

df["price"]Series를 돌려줍니다. 1차원 배열에 인덱스가 붙은 형태이고, .mean() 같은 집계를 바로 걸 수 있습니다. 반면 df[["price"]]는 열이 하나뿐이어도 DataFrame이라 표 모양이 유지됩니다. 이 차이를 모르면 같은 열을 골랐는데 어떤 코드는 되고 어떤 코드는 안 되는 상황을 겪게 됩니다. 한 열의 값을 다루려면 대괄호 하나, 표 형태를 유지하려면 대괄호 둘입니다.

loc와 iloc: 라벨과 위치 #

행을 고를 때는 lociloc를 씁니다. 둘의 구분은 한 줄로 정리됩니다. loc라벨(인덱스 값)로, iloc위치(정수 순번)로 고릅니다. 예를 들어 df.loc[2]df.iloc[2]는 둘 다 불고기 행을 돌려줍니다. 지금은 인덱스가 0부터 차례로 매겨져 있어 같아 보이지만, 필터링을 거치면 라벨과 위치가 어긋납니다.

필터 후에는 둘이 다릅니다
sub = df[df["category"] == "양식"]   # 라벨 4, 5인 행만 남음
sub.loc[4]    # 라벨 4: 파스타
sub.iloc[0]   # 첫 번째 행: 파스타
sub.loc[0]    # KeyError! 라벨 0인 행은 없습니다

“몇 번째 행"이 필요하면 iloc, “인덱스 값이 무엇인 행"이 필요하면 loc입니다. 쉼표를 쓰면 행과 열을 동시에 고를 수 있습니다. 쉼표 앞이 행, 뒤가 열입니다.

행과 열 동시 선택
df.loc[1:3, ["name", "price"]]   # 라벨 1〜3 행의 name, price 열
df.iloc[1:3, 0:2]                # 위치 1〜2 행의 0〜1번째 열

여기에 함정이 하나 있습니다. loc의 슬라이스는 끝 라벨을 포함하고, iloc의 슬라이스는 파이썬 리스트처럼 끝 위치를 제외합니다. 그래서 df.loc[1:3]은 3행, df.iloc[1:3]은 2행이 나옵니다.

불리언 인덱싱: 조건식이 마스크가 됩니다 #

이번 글의 핵심입니다. pandas에서 “조건에 맞는 행만"을 표현하는 모델은 단순합니다. 열에 비교 연산을 걸면 True/False로 이루어진 Series가 나오는데, 이를 마스크라고 부릅니다.

조건식의 결과는 마스크
df["price"] >= 12000
# 0    False
# 1    False
# 2     True
# 3    False
# 4     True
# 5     True
# Name: price, dtype: bool

이 마스크를 대괄호에 넣은 df[df["price"] >= 12000]이 곧 필터입니다. True인 행, 즉 불고기·파스타·피자만 남습니다. 조건을 조합할 때는 and, or, not이 아니라 &, |, ~를 쓰고, 각 조건을 반드시 괄호로 감싸야 합니다.

조건 조합과 괄호
df[(df["category"] == "한식") & (df["price"] >= 12000)]  # 그리고
df[(df["price"] <= 9000) | (df["qty"] >= 30)]            # 또는
df[~(df["category"] == "양식")]                          # 부정

괄호가 필수인 이유는 연산자 우선순위 때문입니다. &>=보다 먼저 평가되므로, 괄호를 빼면 조건이 엉뚱하게 묶여 TypeError가 납니다. and를 쓰면 “The truth value of a Series is ambiguous"라는 ValueError가 납니다. 둘 다 불리언 인덱싱에서 가장 자주 만나는 에러이니, 조건마다 괄호를 치는 습관을 들이는 편이 좋습니다.

조건을 다듬는 도구: isin, between, str.contains #

자주 쓰는 조건 패턴에는 전용 메소드가 있습니다.

isin, between, str.contains
df[df["category"].isin(["한식", "분식"])]   # 값이 목록 안에 있는가
df[df["price"].between(10000, 15000)]       # 범위 안에 있는가 (양끝 포함)
df[df["name"].str.contains("찌개")]         # 문자열에 패턴이 포함되는가

isin은 같은 열의 비교를 |로 늘어놓는 것보다 짧고 읽기 쉽습니다. between은 양끝을 포함하는 범위 조건이고, str.contains는 문자열 열 전용으로 부분 일치를 검사합니다. 셋 모두 결과는 마스크이므로 &, |, ~ 조합에 그대로 섞어 쓸 수 있습니다.

query: 가독성이 필요할 때 #

조건이 길어지면 괄호와 df[...] 반복 때문에 식이 읽기 어려워집니다. 이럴 때 query 메소드가 대안이 됩니다. 조건을 문자열로 적고, andor를 그대로 쓸 수 있습니다.

query 메소드
df.query("price >= 12000 and category == '양식'")
min_price = 10000
df.query("price >= @min_price and qty > 20")   # 외부 변수는 @로 참조

같은 조건을 불리언 인덱싱으로 쓰면 df[(df["price"] >= 12000) & (df["category"] == "양식")]이 되는데, query 쪽이 확실히 눈에 잘 들어옵니다. 다만 조건이 문자열이라 IDE의 자동 완성과 타입 검사가 닿지 않고, 열 이름에 공백이 있으면 백틱으로 감싸야 하는 제약이 있습니다. 기본기는 불리언 인덱싱으로 다지고, 조건이 세 개를 넘어가는 코드에서 query를 가독성 도구로 꺼내 쓰는 정도가 균형이 좋습니다.

SettingWithCopyWarning: 뷰와 복사 #

선택과 필터를 쓰다 보면 반드시 한 번은 이 경고를 만납니다.

경고가 나는 코드
cheap = df[df["price"] < 12000]
cheap["price"] = 0
# SettingWithCopyWarning: A value is trying to be set on a copy of a slice...

원인은 df[...]로 잘라낸 결과가 원본의 (같은 데이터를 바라보는 창)인지 복사본인지 pandas가 보장하지 않는다는 점에 있습니다. 뷰라면 cheap을 고친 것이 df까지 바꾸고, 복사본이라면 cheap만 바뀝니다. 어느 쪽이 될지 상황에 따라 달라지니 할당 결과를 예측할 수 없고, 경고는 “이 할당이 원본에 반영될지 알 수 없다"는 신호입니다. 해법은 의도에 따라 둘로 갈립니다.

의도별 해법
# 원본을 고치는 것이 목적이라면: 자른 뒤 할당하지 말고 .loc로 한 번에
df.loc[df["price"] < 12000, "price"] = 0

# 부분집합을 독립된 데이터로 쓸 것이라면: 처음부터 copy()를 명시
cheap = df[df["price"] < 12000].copy()
cheap["price"] = 0   # 경고 없음, df는 그대로

참고로 pandas 3.0부터는 Copy-on-Write가 기본 동작이 되어, 위와 같은 연쇄 할당은 경고 대신 아예 원본에 반영되지 않는 것으로 확정됩니다. 모호함이 사라지는 방향이지만, “원본 수정은 .loc 한 번에, 부분집합은 copy()로"라는 습관은 어느 버전에서든 그대로 유효합니다.

정리 #

이번 글에서 다룬 내용입니다.

  • 열 선택은 대괄호 하나면 Series, 둘이면 DataFrame
  • loc는 라벨, iloc는 위치이고, 필터 후에는 둘이 어긋남
  • 조건식은 마스크가 되고, 조합은 &, |, ~에 괄호 필수
  • isin, between, str.contains로 자주 쓰는 조건을 짧게 표현
  • 조건이 길어지면 query가 가독성 대안
  • SettingWithCopyWarning은 뷰와 복사의 모호함에 대한 신호이고, 해법은 .loc 한 번 할당과 copy()

다음 글(#4 변형과 결측치)에서는 골라낸 데이터를 바꾸는 단계로 넘어갑니다. 새 열 만들기, apply와 벡터 연산, 그리고 실무 데이터의 절반을 차지하는 결측치 처리(dropna, fillna)를 다루겠습니다.

X