10  데이터 선택과 필터링

Tidy Data가 준비되었다면, 이제 분석에 필요한 데이터를 다룰 차례입니다. 원하는 데이터를 정확하게 선택하고 선별(filter)하는 능력은 필수적일뿐만 아니라 기본적인 기본기입니다. 무엇보다 pandas는 매우 유연한 인덱싱 시스템을 제공합니다. 이는 축복이자 혼란의 시작일 수 있습니다.

10.1 인덱싱의 이론적 배경

데이터프레임에서 데이터를 선택하는 것은 근본적으로 ’어떤 관측치(observation)를 선택할 것인가?’를 결정하는 것입니다. Tidy Data 원칙에서 각 행은 하나의 관측치를 나타내므로, 인덱싱은 관측치의 식별(identification)과 선택(selection)의 문제입니다.

pandas는 두 가지 근본적으로 다른 인덱싱 패러다임을 제공합니다.

  1. Label-based indexing (loc): 데이터의 의미(semantic)에 기반한 접근 방식입니다.
    • 인덱스 이름이나 컬럼 이름을 사용
    • “어떤 국가의 어떤 연도 데이터”처럼 비즈니스 로직과 직결
    • Tidy Data 관점에서 변수명과 관측치 식별자에 직접 매핑
  2. Position-based indexing (iloc): 데이터의 물리적 위치(physical position)에 기반한 접근 방식입니다.
    • 정수 위치(0부터 시작)를 사용
    • “첫 번째 행, 세 번째 열”처럼 순서에 의존
    • 배열이나 리스트의 인덱싱과 유사한 직관

이 두 가지 접근법의 선택은 코드의 의도를 명확히 표현하는 데 중요합니다. 가능하면 loc를 사용하여 “무엇을” 선택하는지 명시하는 것이 좋습니다.

구분 loc (Label-based) iloc (Position-based)
기준 이름(Label/Index) 정수 위치(Integer Position) (0부터 시작)
슬라이싱 start:end (end 포함) start:end (end 제외)
권장 상황 가독성이 중요할 때, 인덱스가 의미 있을 때 데이터 순서가 명확할 때, 반복문 처리 시

가능하면 명시적인 loc를 사용하는 습관을 들이세요. 코드를 읽는 사람에게 “무엇을” 가져오는지 명확히 보여줄 수 있습니다.

10.1.1 기본 사용법

lociloc의 기본 문법은 df.loc[행 선택, 열 선택] 또는 df.iloc[행 선택, 열 선택] 형태입니다. 행과 열을 동시에 지정하거나, 각각 따로 지정할 수 있습니다.

10.1.1.1 단일 값 선택

loc를 사용한 단일 값 선택은 df.loc[행_인덱스, 열_이름] 형태입니다. 행 인덱스와 열 이름을 모두 지정하여 하나의 셀 값을 가져올 수 있습니다. 예를 들어, 첫 번째 행(인덱스 0)의 ‘country’ 열 값을 선택하면 ’Afghanistan’이 반환됩니다.

iloc를 사용한 단일 값 선택은 df.iloc[행_위치, 열_위치] 형태입니다. 행 위치와 열 위치를 모두 정수로 지정합니다. 첫 번째 행(위치 0)과 첫 번째 열(위치 0)을 선택하면 동일하게 ’Afghanistan’이 반환됩니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
print(
    df.loc[0, "country"]
)  # 첫 번째 행(인덱스 0)의 'country' 열 값
print(
    df.iloc[0, 0]
)  # 첫 번째 행(위치 0), 첫 번째 열(위치 0)의 값
Afghanistan
Afghanistan

10.1.1.2 슬라이싱과 리스트 조합

loc에서 슬라이싱과 리스트를 조합하면 df.loc[행_범위, 열_리스트] 형태로 여러 행과 여러 열을 동시에 선택할 수 있습니다. 중요한 점은 loc의 슬라이싱은 end 값을 포함한다는 것입니다. df.loc[0:5, ['country', 'year']]는 인덱스 0부터 5까지의 6개 행(0, 1, 2, 3, 4, 5)과 ‘country’, ‘year’ 열을 선택합니다.

iloc에서도 슬라이싱과 리스트를 조합할 수 있지만, iloc의 슬라이싱은 Python 기본과 동일하게 end 값을 제외한다는 것입니다. df.iloc[0:5, [0, 2]]는 위치 0부터 4까지의 5개 행(0, 1, 2, 3, 4)과 0번, 2번 열을 선택합니다. 위치 5는 포함되지 않습니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

print(df.loc[0:5, ["country", "year"]])
subset_loc = df.loc[0:5, "country"]
print(f"loc 결과 개수: {len(subset_loc)}")

print(df.iloc[0:5, [0, 2]])
subset_iloc = df.iloc[0:5, 0]
print(f"iloc 결과 개수: {len(subset_iloc)}")
       country  year
0  Afghanistan  1952
1  Afghanistan  1957
2  Afghanistan  1962
3  Afghanistan  1967
4  Afghanistan  1972
5  Afghanistan  1977
loc 결과 개수: 6
       country  year
0  Afghanistan  1952
1  Afghanistan  1957
2  Afghanistan  1962
3  Afghanistan  1967
4  Afghanistan  1972
iloc 결과 개수: 5

앞서 설명한 것과 같이 lociloc의 슬라이싱 동작이 다릅니다. 이 차이는 인덱싱의 의미론적 차이에서 비롯됩니다.

loc는 “인덱스 0부터 인덱스 5까지”라는 포함적 범위(inclusive range)를 의미합니다. 이는 수학적 구간 표기법 [0, 5]와 유사합니다. 따라서 df.loc[0:5]는 인덱스 0, 1, 2, 3, 4, 5를 모두 포함하여 총 6개 행이 선택됩니다.

iloc는 “위치 0부터 위치 5 전까지”라는 배타적 범위(exclusive range)를 의미합니다. 이는 Python의 리스트 슬라이싱 [0:5]와 동일합니다. 따라서 df.iloc[0:5]는 위치 0, 1, 2, 3, 4를 포함하여 총 5개 행이 선택되며, 위치 5는 제외됩니다.

이 차이를 실제 데이터로 확인해보면, loc는 6개 행을 반환하고 iloc는 5개 행을 반환합니다. 이는 loc가 end 값을 포함하고, iloc가 end 값을 제외하기 때문입니다.

  • loc[0:5]는 인덱스 0부터 5까지 6개 행을 선택 (end 포함)
  • iloc[0:5]는 위치 0부터 4까지 5개 행을 선택 (end 제외, Python 기본)

인덱스가 연속된 정수일 때는 lociloc의 결과가 다를 수 있으므로 주의가 필요합니다. 인덱스가 의미 있는 이름(날짜, 국가명)일 때는 loc를 사용하는 것이 의도를 명확히 표현합니다.

10.1.2 행과 열 함께 지정하기

앞서 설명한 것과 같이 행과 열을 동시에 지정할 때는 df.loc[행 선택, 열 선택] 또는 df.iloc[행 선택, 열 선택] 형태를 사용합니다. 이 때, :를 사용하면 해당 축의 모든 요소를 선택할 수 있습니다.

loc를 사용하여 행 이름과 열 이름을 조합할 때, 인덱스 범위와 열 리스트를 함께 지정할 수 있습니다. 예를 들어 df.loc[0:10, ['country', 'year', 'lifeExp']]는 인덱스 0부터 10까지의 11개 행(0~10, end 포함)과 ‘country’, ‘year’, ‘lifeExp’ 열을 선택하여 11개 행 × 3개 열의 데이터프레임을 생성합니다.

iloc를 사용하여 행 위치와 열 위치를 조합할 때도 동일한 방식으로 지정할 수 있지만, iloc는 end를 제외하므로 주의가 필요합니다. df.iloc[0:10, [0, 2, 3]]는 위치 0부터 9까지의 10개 행(0~9, end 제외)과 0번, 2번, 3번 열을 선택하여 10개 행 × 3개 열의 데이터프레임을 생성합니다.

모든 행을 선택하면서 특정 열만 선택하려면 행 위치에 :를 사용합니다. df.loc[:, ['country', 'lifeExp']]는 모든 행과 ‘country’, ‘lifeExp’ 열만 선택하여 전체 행 × 2개 열의 데이터프레임을 생성합니다.

반대로 특정 행만 선택하면서 모든 열을 선택하려면 열 위치에 :를 사용합니다. df.loc[0:5, :]는 인덱스 0부터 5까지의 6개 행과 모든 열을 선택하여 6개 행 × 전체 열의 데이터프레임을 생성합니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

subset = df.loc[0:10, ["country", "year", "lifeExp"]]

subset = df.iloc[0:10, [0, 2, 3]]
all_rows_specific_cols = df.loc[:, ["country", "lifeExp"]]
print(all_rows_specific_cols.head(2))

specific_rows_all_cols = df.loc[0:5, :]
print(specific_rows_all_cols.head(2))
       country  lifeExp
0  Afghanistan   28.801
1  Afghanistan   30.332
       country continent  ...      pop   gdpPercap
0  Afghanistan      Asia  ...  8425333  779.445314
1  Afghanistan      Asia  ...  9240934  820.853030

[2 rows x 6 columns]
  • df.loc[:, ['col1', 'col2']]: 모든 행, 특정 열들
  • df.loc[0:5, :]: 특정 행들, 모든 열
  • df.loc[0:5, ['col1', 'col2']]: 특정 행들, 특정 열들
  • df.iloc[:, [0, 1]]: 모든 행, 특정 열 위치들
  • df.iloc[0:5, :]: 특정 행 위치들, 모든 열

10.1.2.1 range() 함수와 슬라이싱 구문 비교

열을 선택할 때는 슬라이싱 구문(:)이 range() 함수보다 간단하고 효율적입니다. 다만 불연속적인 열을 선택할 때는 리스트를 사용해야 합니다.

연속된 열을 선택할 때 range() 함수를 사용하려면 먼저 리스트로 변환해야 합니다. list(range(3))[0, 1, 2]를 생성하며, 이를 df.iloc[:, small_range]와 같이 사용하면 처음 3개 열(0, 1, 2번)을 선택할 수 있습니다. 다만 range()는 정수만 생성하므로 iloc와 함께 사용해야 하며, loc와는 사용할 수 없습니다.

슬라이싱 구문을 사용하면 더 간단하고 직관적입니다. df.iloc[:, :3]는 위치 0부터 3 전까지의 열(0, 1, 2번)을 선택합니다. 슬라이싱은 메모리 효율적이고 가독성이 좋아 권장되는 방법입니다.

간격이 있는 열을 선택할 때는 슬라이싱의 세 번째 인자인 step을 사용할 수 있습니다. df.iloc[:, 0:6:2]는 위치 0부터 6 전까지를 간격 2로 선택하여 0, 2, 4번 열을 가져옵니다. 문법은 [start:end:step] 형태로, start부터 end 전까지 step 간격으로 선택합니다.

연속되지 않은 불연속적인 열을 선택할 때는 리스트를 사용해야 합니다. df.iloc[:, [0, 2, 4]]는 0번, 2번, 4번 열을 선택합니다. 슬라이싱으로는 불연속적인 위치를 선택할 수 없으므로, 이 경우에는 리스트를 사용하는 것이 유일한 방법입니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 주의: range()는 정수만 생성하므로 iloc와 함께 사용해야 합니다
small_range = list(range(3))
subset_range = df.iloc[:, small_range]

# 슬라이싱 구문 사용 (더 간단하고 권장)
subset_slice = df.iloc[:, :3]

# 간격이 있는 경우 (step 사용)
subset_step = df.iloc[:, 0:6:2]

# 연속되지 않은 열을 선택할 때는 리스트를 사용합니다
subset_discrete = df.iloc[:, [0, 2, 4]]
  • 연속된 열: 슬라이싱 df.iloc[:, 0:3] 사용 (간단하고 효율적)
  • 불연속 열: 리스트 df.iloc[:, [0, 2, 4]] 사용
  • 간격 있는 열: 슬라이싱 step df.iloc[:, 0:6:2] 사용

슬라이싱 구문이 더 간단하고 직관적이며 메모리 효율적이므로, 가능하면 슬라이싱을 사용하는 것을 권장합니다.

10.2 Boolean Indexing

조건에 맞는 데이터를 걸러내는 강력한 무기입니다. Boolean Indexing은 Tidy Data 원칙에서 관측치를 조건에 따라 선택하는 핵심 메커니즘입니다.

Boolean Indexing은 논리적 조건(logical condition)을 사용하여 데이터를 필터링하는 방법입니다. 이는 집합론의 관점에서 “조건을 만족하는 원소들의 집합”을 선택하는 것과 동일합니다.

  1. 조건식이 각 행에 대해 평가되어 Boolean 배열(True/False)을 생성합니다.

  2. True인 위치의 행만 선택되어 새로운 DataFrame이 생성됩니다.

  3. 이 과정은 벡터화(vectorized) 연산으로 처리되어 매우 빠릅니다.

Boolean Indexing은 “어떤 관측치가 특정 조건을 만족하는가?”라는 질문에 답합니다. 예를 들어, “기대수명이 80세 이상인 관측치는?”이라는 질문은 df[df['lifeExp'] > 80]으로 표현됩니다.

10.2.1 Boolean Indexing 기본 문법

Boolean Indexing의 기본 문법은 df[조건식] 형태입니다. 조건식은 각 행에 대해 평가되어 Boolean 배열을 반환하고, True인 행만 선택됩니다.

단일 조건을 사용할 때, 조건식 df['lifeExp'] > 80은 각 행에 대해 평가되어 Boolean Series를 반환합니다. 예를 들어 [False, False, True, False, True, ...]와 같은 형태로, 각 행이 조건을 만족하는지 여부를 나타냅니다. 이 Boolean Series에서 True인 위치의 행만 선택되어 새로운 DataFrame이 생성됩니다. 따라서 df[df['lifeExp'] > 80]은 기대수명이 80세 초과인 모든 행을 포함하는 DataFrame을 반환합니다.

복합 조건을 사용할 때는 괄호로 각 조건을 묶어주는 것이 중요합니다. Python의 연산자 우선순위 때문에 각 조건을 괄호로 묶지 않으면 예상과 다른 결과가 나올 수 있습니다. 논리 연산자로는 & (AND), | (OR), ~ (NOT)를 사용하며, Python의 and, or, not와는 다릅니다. 예를 들어 df[(df['lifeExp'] >= 70) & (df['year'] > 2000)]는 기대수명이 70세 이상이고 연도가 2000년 초과인 행을 선택합니다.

조건식의 내부 동작을 이해하기 위해 각 단계를 분리해서 확인할 수 있습니다. 첫 번째 조건 df['lifeExp'] >= 70은 Boolean Series를 생성하며, condition1.sum()으로 조건을 만족하는 행의 개수를 확인할 수 있습니다. 두 번째 조건 df['year'] > 2000도 마찬가지로 Boolean Series를 생성합니다. 두 조건을 & 연산자로 결합하면 두 조건을 모두 만족하는 행만 True가 됩니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 단일 조건
high_life = df[df["lifeExp"] > 80]

# 복합 조건: 연산자 우선순위를 위해 괄호로 묶어줌
target_data = df[
    (df["lifeExp"] >= 70) & (df["year"] > 2000)
]

# 조건식의 내부 동작 이해
# - df['lifeExp'] >= 70 -> Boolean Series 생성
condition1 = df["lifeExp"] >= 70

# - df['year'] > 2000 -> Boolean Series 생성
condition2 = df["year"] > 2000

# - 두 조건의 AND 연산
combined = condition1 & condition2
  • 복합 조건에서 &, |, ~ 연산자를 사용(Python의 and, or, not가 아님)
  • 각 조건을 괄호로 묶어야 연산자 우선순위 문제를 피할 수 있음
  • df[(조건1) & (조건2)] 형태로 작성하는 것이 안전함

10.2.2 복잡한 조건식

여러 조건을 조합하여 복잡한 필터링을 수행할 수 있습니다. 논리 연산자(&, |, ~)를 사용하여 조건을 결합합니다. 여러 조건을 AND 연산으로 조합할 때는 세 가지 이상의 조건을 모두 만족하는 관측치를 선택할 수 있습니다. 예를 들어, 기대수명이 75세 초과이고, 1인당 GDP가 10,000 초과이며, 대륙이 ‘Europe’ 또는 ’Americas’인 관측치를 선택하려면 각 조건을 & 연산자로 연결하고 괄호로 묶어야 합니다. 이렇게 하면 세 가지 조건을 모두 만족하는 행만 선택됩니다.

각 조건의 개별 평가를 통해 필터링 전에 각 조건이 얼마나 많은 행을 만족하는지 확인할 수 있습니다. 이는 복잡한 필터링의 결과를 이해하는 데 도움이 됩니다. 각 조건에 대해 .sum()을 사용하면 Boolean Series에서 True의 개수, 즉 조건을 만족하는 행의 개수를 확인할 수 있습니다.

NOT 조건을 사용할 때는 ~ 연산자를 사용합니다. ~(df['continent'] == 'Asia')는 ’Asia’가 아닌 모든 관측치를 선택합니다. 이는 df['continent'] != 'Asia'와 동일한 결과를 반환하지만, 더 복잡한 조건식에서 유용할 수 있습니다. NOT 연산자는 조건의 부정을 나타내며, 특정 값을 제외한 모든 값을 선택할 때 사용됩니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 여러 조건 조합 -> AND 연산
complex_filter = df[
    (df["lifeExp"] > 75)
    & (df["gdpPercap"] > 10000)
    & (df["continent"].isin(["Europe", "Americas"]))
]

# NOT 조건 -> ~ 연산자 사용
not_asia = df[~(df["continent"] == "Asia")]

# NOT 조건의 다른 표현 방법 -> != 연산자 사용
not_asia_alt = df[df["continent"] != "Asia"]
  • AND: (조건1) & (조건2) & (조건3) - 모든 조건 만족
  • OR: (조건1) | (조건2) | (조건3) - 하나라도 만족
  • NOT: ~(조건) - 조건을 만족하지 않음
  • 복합: ((조건1) | (조건2)) & (조건3) - 괄호로 우선순위 제어

10.2.2.1 isin() 메서드 활용

isin() 메서드는 여러 값 중 하나와 일치하는지 확인하는 편리한 방법입니다. 여러 개의 == 조건을 |로 연결하는 것보다 간결하고 효율적입니다. 여러 값 중 하나와 일치하는 조건을 만들 때, isin() 메서드는 리스트에 포함된 값 중 하나라도 일치하면 True를 반환합니다. 예를 들어 df['continent'].isin(['Asia', 'Europe'])는 대륙이 ‘Asia’ 또는 ’Europe’인 행에 대해 True를 반환합니다. 이는 (df['continent'] == 'Asia') | (df['continent'] == 'Europe')와 동일한 결과를 반환하지만, 훨씬 간결하고 읽기 쉽습니다.

리스트로 여러 조건을 지정할 때는 isin() 메서드에 리스트를 전달하면 됩니다. 예를 들어 특정 연도들에 해당하는 관측치만 선택하려면 df[df['year'].isin([2000, 2005, 2010])]와 같이 사용할 수 있습니다. 이렇게 하면 2000년, 2005년, 2010년에 해당하는 모든 행이 선택됩니다.

isin()의 반대 연산을 수행하려면 ~ 연산자와 함께 사용합니다. ~df['continent'].isin(['Asia', 'Europe'])는 ’Asia’와 ’Europe’를 제외한 모든 관측치를 선택합니다. 이는 특정 값들을 제외한 나머지를 선택할 때 유용한 패턴입니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 여러 값 중 하나와 일치
selected_continents = df[
    df["continent"].isin(["Asia", "Europe"])
]

# isin()을 사용하지 않은 동등한 표현 (더 복잡함)
selected_alt = df[
    (df["continent"] == "Asia")
    | (df["continent"] == "Europe")
]

# 리스트로 여러 조건 지정
selected_years = df[df["year"].isin([2000, 2005, 2010])]

# isin()의 반대: ~ 연산자와 함께 사용
excluded_continents = df[
    ~df["continent"].isin(["Asia", "Europe"])
]
  • isin()의 장점
    • 가독성: 여러 == 조건을 |로 연결하는 것보다 명확
    • 성능: 내부적으로 최적화된 벡터화 연산을 사용
    • 유지보수: 리스트를 변수로 관리하면 조건 변경이 쉬움

그리고 복잡한 조건식의 경우 조건식을 변수로 관리하면 재사용과 수정에 용이합니다. 대부분의 경우 조건식을 변수로 관리하지 않고, 나열하는 방식으로 활용하지만 여러가지 경우의 수를 다루게 될 경우에는 변수 뿐마니 아니라 함수로 생성하는 방법도 고려해볼 수 있습니다.

target_continents = ["Asia", "Europe", "Americas"]
result = df[df["continent"].isin(target_continents)]

10.2.3 pd.col()

pandas 3.x부터 도입된 pd.col()은 함수형 프로그래밍 스타일에 완벽하게 부합하는 선언형 문법을 제공합니다. 이는 lambda 함수를 사용하지 않고도 컬럼을 참조할 수 있게 해줍니다.

pd.col()은 선언형(declarative) 프로그래밍 스타일을 지원합니다. lambda 함수를 사용하는 대신, 컬럼을 직접 참조하여 표현식을 구성할 수 있습니다.

  • 가독성: lambda 함수보다 더 읽기 쉬움
  • 함수형 스타일: 함수형 프로그래밍 원칙과 일치
  • 타입 안정성: IDE에서 더 나은 자동완성 지원 가능
import pandas as pd

df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})

result1 = df.assign(c=lambda x: x["a"] + x["b"])
result2 = df.assign(c=pd.col("a") + pd.col("b"))

pd.col()은 Boolean Indexing과 함께 사용할 수 있습니다.

import pandas as pd

df = pd.DataFrame(
    {"a": [1, 2, 3, 4, 5], "b": [10, 20, 30, 40, 50]}
)

# pd.col()을 사용한 조건식
result = df[pd.col("a") > 2]

# 복합 조건
result = df[(pd.col("a") > 2) & (pd.col("b") < 40)]

pd.col()은 다양한 연산을 지원합니다. 산술, 비교 및 문자열 연산까지 지원하기 때문에 기존의 lambda 함수를 사용하는 방식보다 더 간결하고 가독성이 좋은 코드를 작성할 수 있습니다.

import pandas as pd

df = pd.DataFrame(
    {
        "a": [1, 2, 3],
        "b": [4, 5, 6],
        "name": ["Alice", "Bob", "Charlie"],
    }
)

# 산술 연산
result = df.assign(
    sum_col=pd.col("a") + pd.col("b"),
    diff_col=pd.col("b") - pd.col("a"),
    prod_col=pd.col("a") * pd.col("b"),
    div_col=pd.col("b") / pd.col("a"),
)

# 비교 연산
result = df.assign(
    is_large=pd.col("a") > 2,
    is_equal=pd.col("a") == pd.col("b"),
)

# 문자열 메서드 (pandas 3.x+)
result = df.assign(
    name_length=pd.col("name").str.len(),
    name_upper=pd.col("name").str.upper(),
)

10.2.4 query() 메서드

복잡한 Boolean Indexing은 괄호가 중첩되기 때문에 가독성이 좋지 못하다는 평가가 있습니다. 이를 해결하기 위해서 SQL처럼 읽기 쉬운 형태의 조건식을 작성할 수 있는 query()를 제공합니다.

query() 메서드는 Boolean Indexing을 선언형(declarative) 스타일로 작성할 수 있게 해주는 기능입니다. 이는 SQL의 WHERE 절과 유사한 문법을 제공하여, “어떤 데이터를 원하는가”를 직접적으로 표현할 수 있습니다.

  • 컬럼 이름을 따옴표 없이 직접 사용할 수 있음
  • and, or, not 키워드를 사용함 (Python의 논리 연산자와 동일)
  • 외부 변수는 @ 기호로 참조

10.2.4.1 기본 사용법

query() 메서드는 문자열로 조건을 표현합니다. 컬럼 이름은 따옴표 없이 사용하고, 문자열 값은 따옴표로 감싸야 합니다.

단순 조건을 사용할 때, query() 메서드 내에서 컬럼 이름은 따옴표 없이 직접 사용할 수 있습니다. 예를 들어 df.query('lifeExp > 80')에서 ’lifeExp’는 컬럼 이름으로 인식되며, 이는 df[df['lifeExp'] > 80]과 동일한 결과를 반환합니다. query() 메서드는 Boolean Indexing과 동일한 결과를 반환하지만, 문자열로 조건을 표현하여 가독성이 더 좋습니다.

복합 조건에서 AND 연산을 사용할 때는 and 키워드를 사용합니다. 이는 Python의 and 키워드와 동일하며, Boolean Indexing의 & 연산자와는 다릅니다. df.query('lifeExp >= 70 and year > 2000')는 기대수명이 70세 이상이고 연도가 2000년 초과인 행을 선택합니다. 이는 df[(df['lifeExp'] >= 70) & (df['year'] > 2000)]와 동일한 결과를 반환합니다.

복합 조건에서 OR 연산을 사용할 때는 or 키워드를 사용합니다. 문자열 값을 사용할 때는 따옴표로 감싸야 하며, 외부 문자열 따옴표와 구분하기 위해 이중 따옴표를 사용하는 것이 좋습니다. df.query('continent == "Asia" or continent == "Europe"')는 대륙이 ‘Asia’ 또는 ’Europe’인 행을 선택합니다. 이는 df[(df['continent'] == 'Asia') | (df['continent'] == 'Europe')]와 동일한 결과를 반환합니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 단순 조건
result = df.query("lifeExp > 80")

# Boolean Indexing과의 비교
result_alt = df[df["lifeExp"] > 80]

# 복합 조건 (AND)
result = df.query("lifeExp >= 70 and year > 2000")

# Boolean Indexing과의 비교
result_alt = df[(df["lifeExp"] >= 70) & (df["year"] > 2000)]

# 복합 조건 (OR)
result = df.query(
    'continent == "Asia" or continent == "Europe"'
)

# Boolean Indexing과의 비교
result_alt = df[
    (df["continent"] == "Asia")
    | (df["continent"] == "Europe")
]
  • 컬럼 이름: 따옴표 없이 사용 (lifeExp, year)
  • 문자열 값: 따옴표로 감싸야 함 ("Asia", "Europe")
  • 논리 연산자: and, or, not (Python 키워드 사용)
  • 비교 연산자: ==, !=, >, <, >=, <=

10.2.4.2 변수 참조 (@ 사용)

query() 문자열 내에서 외부 변수를 참조하려면 @ 기호를 사용합니다. 이는 동적 조건을 만들 때 매우 유용합니다.

외부 변수를 참조할 때는 @ 기호를 변수명 앞에 붙입니다. 예를 들어 min_life = 75max_life = 85를 정의한 후, df.query('lifeExp >= @min_life and lifeExp <= @max_life')와 같이 사용하면 Python 변수를 query() 문자열 내에서 참조할 수 있습니다. 이렇게 하면 조건을 하드코딩하지 않고 변수로 관리할 수 있어 코드의 재사용성과 유지보수성이 향상됩니다. 변수 값을 변경하면 다른 결과를 얻을 수 있어, 동적으로 조건을 조정할 수 있습니다.

리스트도 @ 기호로 참조할 수 있습니다. target_continents = ['Asia', 'Europe']와 같이 리스트를 정의한 후, df.query('continent in @target_continents')와 같이 사용하면 리스트에 포함된 값 중 하나와 일치하는 행을 선택할 수 있습니다. 리스트를 동적으로 변경하면 필터링 조건도 함께 변경되어, 다양한 조합의 조건을 쉽게 테스트할 수 있습니다.

함수의 결과도 참조할 수 있습니다. 예를 들어 median_gdp = df['gdpPercap'].median()으로 중앙값을 계산한 후, df.query('gdpPercap > @median_gdp')와 같이 사용하면 계산된 값을 조건에 활용할 수 있습니다. 이는 동적으로 계산된 임계값을 사용하여 필터링할 때 유용합니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 외부 변수 참조: @ 기호를 사용하여 Python 변수를 참조함
min_life = 75
max_life = 85
target_data = df.query(
    "lifeExp >= @min_life and lifeExp <= @max_life"
)

# 변수를 변경하면 다른 결과를 얻을 수 있음
min_life = 80
max_life = 90
target_data_new = df.query(
    "lifeExp >= @min_life and lifeExp <= @max_life"
)

# 리스트 참조: 리스트도 @ 기호로 참조할 수 있음
target_continents = ["Asia", "Europe"]
result = df.query("continent in @target_continents")

# 리스트를 동적으로 변경
target_continents = ["Asia", "Europe", "Americas"]
result_expanded = df.query(
    "continent in @target_continents"
)

# 함수 결과도 참조 가능
import numpy as np

median_gdp = df["gdpPercap"].median()
high_gdp = df.query("gdpPercap > @median_gdp")
  • @변수명: Python 네임스페이스의 변수를 참조
  • @함수(): Python 함수의 결과를 참조
  • @리스트: Python 리스트를 참조

10.2.4.3 문자열 조건

문자열 메서드를 query()에서 사용하려면 .str 접근자를 사용하면 됩니다. 문자열 포함 검색을 수행할 때는 .str.contains() 메서드를 사용합니다. 이 메서드는 문자열에 특정 패턴이 포함되어 있는지 확인합니다. 예를 들어 df.query('country.str.contains("Korea")', engine='python')는 국가 이름에 ’Korea’가 포함된 행을 선택합니다. 이는 df[df['country'].str.contains('Korea')]와 동일한 결과를 반환합니다.

문자열 시작 검색을 수행할 때는 .str.startswith() 메서드를 사용합니다. 이 메서드는 문자열이 특정 패턴으로 시작하는지 확인합니다. df.query('country.str.startswith("United")', engine='python')는 국가 이름이 ’United’로 시작하는 행을 선택합니다.

문자열 끝 검색을 수행할 때는 .str.endswith() 메서드를 사용합니다. 이 메서드는 문자열이 특정 패턴으로 끝나는지 확인합니다. df.query('country.str.endswith("Republic")', engine='python')는 국가 이름이 ’Republic’로 끝나는 행을 선택합니다.

대소문자 구분 없이 검색하려면 Boolean Indexing을 사용하는 것이 더 유연합니다. query()에서는 case=False 옵션을 사용하는 것이 제한적이므로, 대소문자를 구분하지 않는 검색이 필요할 때는 df[df['country'].str.contains('korea', case=False)]와 같이 Boolean Indexing을 사용하는 것이 좋습니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

# 문자열 포함 검색
result = df.query('country.str.contains("Korea")')
print("'Korea'가 포함된 국가:")
print(result[["country", "year", "lifeExp"]])

# Boolean Indexing과의 비교
result_alt = df[df["country"].str.contains("Korea")]
print(
    f"\n두 방법의 결과가 동일한가? {result.equals(result_alt)}"
)

# 문자열 시작 검색
result = df.query('country.str.startswith("United")')
print("\n'United'로 시작하는 국가:")
print(result[["country", "year"]].drop_duplicates())

# 문자열 끝 검색
result = df.query('country.str.endswith("Republic")')
print("\n'Republic'로 끝나는 국가:")
print(result[["country"]].drop_duplicates())

# 대소문자 구분 없이 검색
result_case = df[
    df["country"].str.contains("korea", case=False)
]
print(
    f"\n대소문자 구분 없이 'korea' 검색 결과: {len(result_case)}행"
)
'Korea'가 포함된 국가:
              country  year  lifeExp
828  Korea, Dem. Rep.  1952   50.056
829  Korea, Dem. Rep.  1957   54.081
830  Korea, Dem. Rep.  1962   56.656
..                ...   ...      ...
849       Korea, Rep.  1997   74.647
850       Korea, Rep.  2002   77.045
851       Korea, Rep.  2007   78.623

[24 rows x 3 columns]

두 방법의 결과가 동일한가? True

'United'로 시작하는 국가:
             country  year
1596  United Kingdom  1952
1597  United Kingdom  1957
1598  United Kingdom  1962
...              ...   ...
1617   United States  1997
1618   United States  2002
1619   United States  2007

[24 rows x 2 columns]

'Republic'로 끝나는 국가:
                       country
252   Central African Republic
396             Czech Republic
432         Dominican Republic
1368           Slovak Republic

대소문자 구분 없이 'korea' 검색 결과: 24행
  • 복잡한 문자열 조건은 Boolean Indexing이 더 유연할 수 있음
  • pandas 3.x부터 기본 문자열 dtype이 str(PyArrow 기반)로 변경되어 성능이 향상됨

일반적으로 query()는 복잡한 조건에서 가독성이 좋지만, 단순한 조건에서는 Boolean Indexing이 약간 더 빠를 수 있습니다. 다만 큰 데이터셋에서는 query()의 내부 최적화가 더 유리할 수 있습니다.

단순한 조건에서 Boolean Indexing은 파싱 오버헤드가 없어 직접적인 벡터화 연산을 수행하므로 약간 더 빠를 수 있습니다. df[(df['lifeExp'] > 75) & (df['year'] > 2000)]와 같은 형태는 조건식을 직접 평가하여 Boolean Series를 생성하고 필터링을 수행합니다.

반면 query() 메서드는 내부적으로 numexpr 라이브러리를 사용하여 최적화되지만, 문자열을 파싱하는 오버헤드가 있습니다. df.query('lifeExp > 75 and year > 2000')는 먼저 문자열을 파싱한 후 최적화된 연산을 수행합니다. 단순한 조건에서는 이 파싱 오버헤드가 성능에 영향을 줄 수 있습니다.

복잡한 조건에서 여러 조건이 결합되면 query()의 최적화가 더 유리할 수 있습니다. numexpr은 중간 배열 생성을 최소화하고 벡터화 연산을 최적화하므로, 여러 조건이 결합된 경우 Boolean Indexing보다 성능이 좋을 수 있습니다. 특히 큰 데이터셋에서는 이 차이가 더 두드러집니다.

  • 단순 조건: Boolean Indexing이 약간 빠를 수 있음
  • 복잡한 조건: query()의 내부 최적화가 유리할 수 있음
  • 가독성: 복잡한 조건에서는 query()가 훨씬 읽기 쉬움
  • 실무 권장: 가독성을 우선하고, 성능이 문제가 될 때만 최적화

10.2.5 인덱싱의 메모리 관점

pandas 인덱싱 연산의 혼란스러운 부분은 어떤 연산이 뷰(View)를 반환하고 어떤 연산이 복사본(Copy)을 반환하는지였습니다. pandas 3.x부터는 Copy-on-Write(CoW)가 기본 동작으로, 모든 인덱싱 연산이 논리적으로 복사처럼 동작합니다. 이는 함수형 프로그래밍의 불변성(Immutability) 원칙과 완벽하게 일치합니다.

  1. 모든 인덱싱 연산은 논리적으로 복사본을 반환합니다
  2. 실제 물리적 복사는 수정이 발생하는 시점에만 이루어집니다
  3. 원본 데이터는 항상 보호되며, 부수 효과(side effect)가 없습니다

10.2.5.1 Chained Assignment의 완전한 제거

pandas 3.x에서는 Chained Assignment 패턴이 완전히 제거되었습니다. 코드의 예측 가능성을 높이기 위해서는 loc를 사용한 직접 할당을 사용하는 것이 좋습니다.

import pandas as pd

df = pd.DataFrame({'A': [1, 2, 3], 'B': [4, 5, 6]})
df = df.copy()  # 명시적 복사
df.loc[df['B'] > 5, 'A'] = 200

10.2.5.2 inplace=True의 변화

inplace=True는 권장되지 않습니다. PDEP-8의 향후 변경사항을 고려한다면 inplace=True는 여전히 권장되지 않습니다. 재할당(df = df.drop(0))이 더 명확하고, pandas 3.x의 CoW 메커니즘 하에서 inplace=True의 성능 이점은 거의 없습니다.

10.2.5.3 Fancy Indexing의 내부 구현

Fancy indexing (정수 리스트나 Boolean 배열 사용)은 메모리가 불연속적이어서 항상 복사본을 반환합니다. CoW에서는 논리적 복사로 시작하지만, 실제로는 수정 시점에 물리적 복사가 발생합니다.

import pandas as pd

df = pd.DataFrame(
    {"A": [1, 2, 3, 4, 5], "B": [10, 20, 30, 40, 50]}
)

# 정수 리스트 인덱싱 (Fancy indexing)
indices = [0, 2, 4]
subset = df.iloc[indices]  # 논리적 복사

# 수정 시점에 실제 복사 발생
subset["A"] = [100, 200, 300]
print(df["A"].tolist())  # [1, 2, 3, 4, 5]
print(subset["A"].tolist())  # [100, 200, 300]
[1, 2, 3, 4, 5]
[100, 200, 300]

CoW 메커니즘 하에서도 메모리 효율적인 패턴을 사용하는 것이 좋습니다.

import pandas as pd

df = pd.DataFrame(
    {"A": range(1000), "B": range(1000, 2000)}
)

result = df[(df["A"] > 500) & (df["B"] < 1500)]
result = df.query("A > 500 and B < 1500")

10.3 MultiIndex

다차원 데이터를 다루는 과정에서 pandas는 MultiIndex(계층적 인덱스)라는 강력한 기능을 제공하여, 복잡한 계층 구조의 데이터를 2차원 DataFrame 내에서 자연스럽게 표현할 수 있습니다. 예를 들어 시계열 데이터에서는 연도, 분기, 월과 같은 다양한 시간 계층을 하나의 행 인덱스에 결합할 수 있으며, 지리적 데이터에서는 대륙, 국가, 지역 등 여러 단계의 범주를 계층적으로 구조화할 수 있습니다. 실험 데이터 역시 실험군, 반복, 측정값과 같은 다양한 수준의 그룹이 존재할 때 MultiIndex를 활용하면 각각의 관찰 단위를 쉽고 유연하게 다룰 수 있습니다.

  • 시계열 데이터: 연도 -> 분기 -> 월
  • 지리적 데이터: 대륙 -> 국가 -> 지역
  • 실험 데이터: 실험군 -> 반복 -> 측정값

MultiIndex는 특히 groupby와 같은 연산을 통해 데이터 집계 결과를 표현할 때 자주 등장합니다. 이런 계층적 인덱스를 사용하면, 데이터의 여러 그룹 수준에서 집계와 분석을 손쉽게 수행할 수 있을 뿐 아니라, 상위 그룹부터 하위 그룹까지 데이터를 체계적으로 탐색할 수 있는 장점이 있습니다. 그러나 실제로 분석을 마무리하고 결과를 파일로 저장하거나 시각화할 때는 이러한 계층적 인덱스를 평탄화하여 하나의 열로 바꾸는 편이 더 적합한 경우가 많습니다. 이를 위해 보통 reset_index() 메서드를 사용하여 인덱스의 값을 다시 데이터의 일반 열로 되돌리는 작업이 이루어집니다. 이는 Tidy Data 원칙에 부합하도록, 각 관측치가 하나의 행에 대응하도록 데이터를 구조화하는 데에도 도움이 됩니다.

실제로 현업에서 분석가들에게 의견을 들어보면, MultiIndex를 적극적으로 사용하는 경우는 상대적으로 적으며, 생성된 인덱스를 평탄화하여 처리하는 경우가 훨씬 더 많습니다. 그 이유 중 하나는 MultiIndex가 개념적으로나 사용상으로 다소 직관적이지 않기 때문입니다. 특히 SQL의 계층적 데이터를 많이 다뤄본 사용자라 할지라도 pandas의 MultiIndex는 이질적으로 느껴질 수 있습니다. 그럼에도 불구하고, MultiIndex는 특정 유형의 데이터(금융 데이터에서 여러 ticker의 시계열 정보 등)를 더 효율적으로 표현할 수 있으며, 복잡한 집계 연산을 간편하게 처리할 수 있다는 장점이 있습니다.

따라서 MultiIndex 기능을 완전히 배제하여 평탄화를 진행하기보다는, 그 기본 원리와 사용법을 이해하는 것이 중요합니다. MultiIndex는 다루기에 약간의 학습곡선이 필요한 기능이지만, 데이터 그룹화와 집계 연산을 더 명확하고 유연하게 수행할 수 있도록 해줍니다. 우리는 필요 이상으로 MultiIndex의 심화 기능을 다루기보다는, 기본적인 구조와 활용법에 초점을 맞추어 설명할 것이며, 실제 분석 작업에서 직면할 수 있는 경우에 효과적으로 접근할 수 있도록 실용적인 관점에서 접근하도록 하겠습니다.

10.3.1 MultiIndex 생성

MultiIndex는 pandas에서 여러 열을 인덱스로 설정함으로써, 데이터프레임에 계층적(다층) 인덱스를 부여하는 기능입니다. 예를 들어, set_index() 메서드에 여러 컬럼명을 전달하면 각 열이 서로 다른 단계(Level)를 나타내는 계층 구조가 만들어집니다. 이 계층 구조에서 각 레벨은 데이터의 특정 그룹화 단위를 의미하며(연도, 분기, 지역 등), 행마다 인덱스가 단일 값이 아니라 여러 값으로 이루어진 튜플 형태로 지정됩니다. 예를 들어 (‘2020’, ‘Q1’, ‘North’)와 같은 인덱스는 2020년 1분기 북부 지역의 데이터를 유일하게 식별하는 역할을 하게 됩니다. 이러한 MultiIndex 구조는 데이터 내에서 상위(연도)와 하위(분기, 지역) 계층을 체계적으로 그룹화하고, 복잡한 집계나 분석 작업을 보다 명확하게 수행할 수 있도록 지원합니다. 따라서 각 계층의 값을 조합하여 행을 특정할 수 있고, 여러 수준에서 그룹별로 연산을 적용하거나 데이터를 손쉽게 분할하고 탐색할 수 있는 장점이 있습니다.

import pandas as pd
import numpy as np

df = pd.DataFrame(
    {
        "year": [
            2020,
            2020,
            2021,
            2021,
            2020,
            2020,
            2021,
            2021,
        ],
        "quarter": [
            "Q1",
            "Q2",
            "Q1",
            "Q2",
            "Q1",
            "Q2",
            "Q1",
            "Q2",
        ],
        "region": [
            "North",
            "North",
            "North",
            "North",
            "South",
            "South",
            "South",
            "South",
        ],
        "sales": [100, 120, 110, 130, 90, 100, 95, 105],
    }
)

df_indexed = df.set_index(["year", "quarter", "region"])

MultiIndex를 생성하는 경우는 생각보다 없지만, 아래와 같이 groupby 연산의 결과로 자동으로 생성되는 경우가 있습니다.

df_gapminder = pd.read_csv(
    "../data/gapminder.tsv", sep="\t"
)
grouped = df_gapminder.groupby(["continent", "year"])[
    "lifeExp"
].mean()

10.3.2 MultiIndex 데이터 접근

MultiIndex 데이터에 접근하는 방법은 여러 가지가 있습니다. loc를 사용하여 특정 레벨의 값을 선택할 수 있습니다. loc로 특정 레벨의 값을 선택하는 경우, 튜플 형태(상위레벨값, 하위레벨값)로 접근합니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
grouped = df.groupby(["continent", "year"])[
    "lifeExp"
].mean()

print(f"Africa, 1952: {grouped.loc[('Africa', 1952)]}")
print(f"Asia, 2007: {grouped.loc[('Asia', 2007)]}")
Africa, 1952: 39.1355
Asia, 2007: 70.72848484848485

상위레벨을 지정하면 하위레벨의 모든 값을 선택합니다.

africa_data = grouped.loc["Africa"]

튜플 슬라이싱을 사용하여 범위를 지정할 수 있습니다.

africa_range = grouped.loc[
    ("Africa", 1952):("Africa", 1972)
]

MultiIndex에서 데이터에 접근하는 방법을 요약하면 아래와 같습니다.

  • grouped.loc[(level1, level2)]: 특정 튜플 인덱스 접근
  • grouped.loc['level1']: 상위 레벨만 지정 (하위 레벨 전체)
  • grouped.loc[(level1, start):(level1, end)]: 범위 지정

10.3.3 MultiIndex 조작

10.3.3.1 reset_index(): MultiIndex를 컬럼으로 변환

MultiIndex를 사용할 때 흔하게 쓰이는 해결책입니다. MultiIndex를 일반 컬럼으로 평탄화시켜서 Tidy Data 형태(One row, One observation)로 되돌립니다. MultiIndex는 분석 과정에서는 유용하지만, 최종 결과를 저장하거나 시각화할 때는 평탄화(flattening)하는 것이 좋습니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
grouped = df.groupby(["continent", "year"])[
    "lifeExp"
].mean()

grouped_reset = grouped.reset_index()

특정 레벨만 평탄화할 수 있습니다.

grouped_partial = grouped.reset_index(level="year")

MultiIndex를 평탄화 하는 대표적인 경우는 아래와 같습니다.

  • 최종 결과를 CSV로 저장할 때
  • 시각화 라이브러리(seaborn, matplotlib)에 전달할 때
  • Tidy Data 원칙에 맞춰 데이터를 정리할 때

10.3.3.2 droplevel(): 불필요한 인덱스 레벨 제거

droplevel() 메서드는 여러 단계(MultiIndex)로 구성된 인덱스에서 특정 레벨만을 제거할 때 사용됩니다. 이 메서드는 제거하려는 인덱스 레벨을 선택적으로 없애되, 해당 레벨이 가지고 있던 정보는 결과 데이터에서 완전히 사라지게 만듭니다. 이는 reset_index()와의 중요한 차이점이기도 합니다. reset_index()는 지정한 인덱스 레벨을 일반 컬럼으로 변환해 데이터의 구조를 평탄화하면서 기존 정보를 보존하는 반면, droplevel()은 단순히 해당 인덱스 레벨을 인덱스 구조에서 제외해, 그 레벨의 값이 표면적으로 노출되거나 컬럼으로 남지 않고 데이터셋에서 없어지게 됩니다. 따라서 droplevel()을 사용할 때에는 정보 손실에 주의해야 하며, 불필요한 인덱스 계층을 간결하게 만들고자 할 때 활용하는 것이 좋습니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
grouped = df.groupby(["continent", "year"])[
    "lifeExp"
].mean()

grouped_dropped = grouped.droplevel("year")
grouped_dropped_multi = grouped.droplevel(["year"])

10.3.3.3 swaplevel(): 인덱스 순서 변경

swaplevel() 메서드는 MultiIndex 객체에서 두 개의 인덱스 레벨 순서를 서로 바꾸는 기능을 제공합니다. 인덱스 레벨의 순서를 변경하면, 이후 데이터에 접근할 때 보다 직관적으로 원하는 레벨을 앞쪽에 위치시켜 쉽게 선택할 수 있으며, 다양한 형태로 데이터를 구조화하거나 재구성할 때 큰 도움이 됩니다. 예를 들어, 원래는 ’continent’가 첫 번째, ’year’가 두 번째 인덱스였다면, swaplevel('year', 'continent')을 호출함으로써 ’year’를 앞에 두고 데이터를 조회할 수 있습니다. 이처럼 swaplevel은 특정 레벨을 최상위 인덱스로 이동시켜 그룹별 집계 결과를 빠르게 찾거나, 데이터의 계층 구조를 임시로 바꾸어 가독성이나 연산 편의성을 높이는 데에 자주 활용됩니다. 따라서 복잡하게 중첩된 MultiIndex 구조의 데이터를 다룰 때, swaplevel을 적절히 사용하면 원하는 데이터 포인트에 보다 쉽게 접근하고, 필요한 형태로 데이터를 재정렬하는 작업이 훨씬 용이해집니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
grouped = df.groupby(["continent", "year"])[
    "lifeExp"
].mean()

grouped_swapped = grouped.swaplevel("year", "continent")
grouped_swapped_sorted = grouped_swapped.sort_index()

10.3.4 MultiIndex를 활용한 데이터 선택

MultiIndex DataFrame에서 특정 인덱스 레벨의 값을 선택하는 상황은 자주 발생합니다. 이때 직관적이고 강력한 도구 중 하나가 바로 xs() 메서드입니다. xs()는 MultiIndex를 가진 DataFrame에서 한 레벨 또는 여러 레벨의 값을 기준으로 손쉽게 원하는 부분을 추출하도록 해줍니다. 예를 들어, 대륙이 ’Africa’인 데이터만 뽑거나, 대륙이 ’Africa’이고 연도가 2007년인 데이터만 골라낼 수 있습니다.

이 메서드는 원하는 인덱스 레벨 명칭과 값을 명확하게 지정할 수 있기 때문에 코드를 간결하고 읽기 쉽게 만들어 주며, 복수의 레벨을 동시에 지정해서 다단계 필터링도 손쉽게 할 수 있습니다. 여러 인덱스 값을 튜플로 넘기면 복합 조건에도 대응할 수 있고, drop_level 매개변수를 활용하여 선택한 인덱스 레벨 자체를 추출 결과에서 제거할 것인지 정할 수 있습니다. 이를 통해, 단순히 데이터를 선택하는 것에서 더 나아가, 최종적으로 얻은 데이터의 인덱스 구조 역시 상황에 맞게 조정할 수 있습니다.

실제 분석 작업에서는 xs()의 이러한 특징 덕분에 MultiIndex 데이터를 효율적으로 다루고, 복잡한 인덱스 구조 안에서도 원하는 정보를 빠르게 찾아낼 수 있다는 이점이 있습니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")
df_multi = df.set_index(["continent", "year", "country"])

africa_data = df_multi.xs("Africa", level="continent")
africa_2007 = df_multi.xs(
    ("Africa", 2007), level=["continent", "year"]
)

10.4 데이터 선택과 필터링에 관한 주의사항

데이터 선택과 필터링의 성능에 관한 몇가지 주의사항을 살펴보겠습니다.

10.4.1 인덱스 활용

데이터프레임에서 인덱스를 적절하게 설정하는 것은 데이터 선택 및 조회의 성능을 크게 향상시키는 중요한 전략입니다. 인덱스는 내부적으로 해시 테이블 구조를 기반으로 구현되어 있기 때문에, 인덱스를 사용하지 않을 경우 각 행을 처음부터 끝까지 순차적으로 검색하게 되어 시간 복잡도가 \(O(n)\)이지만, 인덱스를 설정하면 해당 값에 직접 접근할 수 있어 조회 속도가 \(O(1)\)에 가까워집니다. 예를 들어, 데이터에서 자주 조회하거나 자주 필터링하는 컬럼이 있다면, 이를 인덱스로 설정하는 것이 좋습니다. 또한, groupby와 같은 집계 연산에서 기준이 되는 컬럼을 인덱스로 지정하면 관련 연산이 더욱 빠르고 효율적으로 동작합니다. 시계열 데이터의 경우에는 날짜 컬럼을 인덱스로 설정하면 시간순 정렬이나 부분 범위 조회가 매우 신속하게 이뤄집니다. 이처럼, 데이터의 특성과 사용 빈도를 고려하여 적절한 컬럼을 인덱스로 선택하는 것이 데이터 분석의 성능과 효율성을 크게 높일 수 있는 핵심 요소입니다.

import pandas as pd
import time

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

df_indexed = df.set_index("country")
result1 = df_indexed.loc["Korea, Rep."]
result2 = df[df["country"] == "Korea, Rep."]

10.4.2 query() vs Boolean Indexing

데이터를 필터링할 때는 성능과 가독성 두 가지 측면을 모두 고려해야 하며, 상황에 맞는 방식을 선택하는 것이 중요합니다. 단순한 조건이 1~2개 정도라면 Boolean Indexing(불리언 인덱싱)이 직관적이고 빠른 방법입니다. 이 방식은 코드를 해석하는 과정에서 별도의 파싱 오버헤드가 거의 없어, 작은 규모의 논리 연산에서는 속도 면에서도 우수합니다. 하지만 조건이 3개 이상으로 복잡해질수록, 불리언 인덱싱은 각 조건에 대한 중간 결과 배열이 여러 개 생성되어 메모리 소모가 커지고 코드도 길어집니다.

이럴 때는 query() 메서드를 사용하는 것이 더욱 효과적입니다. query()는 내부적으로 numexpr 라이브러리를 활용하여 조건식을 최적화하며, 복잡한 조건을 한 번에 처리할 때 중간 연산 배열의 생성이 최소화됩니다. 이로 인해 특히 대형 데이터셋에서는 성능상의 이점을 누릴 수 있을 뿐만 아니라, 가독성도 현저히 높아집니다. 또한, 동적으로 값을 삽입하거나 외부 변수를 사용할 때는 query()@ 기호를 통해 Python 변수와 연동하여 유연한 조건식을 작성할 수도 있습니다.

요약하자면, 조건이 단순할 경우에는 불리언 인덱싱이, 조건이 복잡해지거나 동적 변수를 활용해야 할 때는 query()가 더 좋은 선택이 됩니다. 각각의 특성을 이해하고 데이터의 성격에 맞게 적절한 방식을 선택하는 것이 코드의 품질과 실행 효율을 모두 높이는 방법입니다.

import pandas as pd

df = pd.read_csv(
    "../data/학원교습소정보(20251231).csv",
    encoding="cp949",
    low_memory=False,
)

result1 = df[df["정원합계"] > 100]
result2 = df.query("정원합계 > 100")

result3 = df[
    (df["정원합계"] > 100)
    & (df["일시수용능력인원합계"] > 50)
    & (df["등록일자"] > 20200000)
]

# query()가 가독성이 좋음
result4 = df.query(
    "정원합계 > 100 and \
    일시수용능력인원합계 > 50 and \
        등록일자 > 20200000"
)

10.4.3 메서드 체이닝 최적화

데이터 분석 과정에서는 중간 변수를 많이 생성하는 대신, 메서드 체이닝을 활용하는 것이 현대 pandas의 권장 패턴입니다. 중간 변수를 여러 번 선언하면 불필요하게 메모리가 소모될 뿐 아니라, 코드의 가독성이 떨어지고 데이터 흐름을 파악하기 어렵게 됩니다. 반면, 메서드 체이닝을 사용하면 데이터 변환의 전체 과정을 파이프라인 형태로 한눈에 볼 수 있어 변환 과정을 명확하게 드러낼 수 있습니다. 코딩 스타일 측면에서도 함수형 프로그래밍의 연속성과 일관성을 유지할 수 있을 뿐만 아니라, Copy-on-Write(CoW) 메커니즘 덕분에 원본 데이터가 안전하게 보호되는 이점이 있습니다. 이러한 방식은 연속적인 데이터 변환에도 중간 변수 없이 결과를 바로 도출할 수 있어, 코드 작성이 훨씬 효율적이고 유지보수도 용이합니다. 결과적으로, 메서드 체이닝은 코드의 효율성과 안전성, 가독성을 모두 높이는 현대적인 데이터 처리 방식입니다.

import pandas as pd

df = pd.read_csv("../data/gapminder.tsv", sep="\t")

result = (
    df.query("year > 2000")  # 논리적 복사
    .groupby("continent")["lifeExp"]  # 그룹 객체
    .mean()  # 집계 결과
)

complex_result = (
    df.query("year >= 2000 and lifeExp >= 70")
    .assign(
        gdp_category=lambda x: pd.cut(
            x["gdpPercap"],
            bins=[0, x["gdpPercap"].median(), float("inf")],
            labels=["Low", "High"],
        )
    )
    .groupby(["continent", "gdp_category"])["lifeExp"]
    .mean()
    .reset_index()
    .sort_values("lifeExp", ascending=False)
)