데이터 인덱싱 및 선택

2장에서는 NumPy 배열의 값에 접근하고, 설정하고, 수정하는 방법과 도구를 자세히 살펴보았습니다. 여기에는 인덱싱(예: arr[2, 1]), 슬라이싱(예: arr[:, 1:5]), 마스킹(예: arr[arr > 0]), 팬시 인덱싱(예: arr[0, [1, 5]]) 및 이들의 조합(예: arr[:, [1, 5]])이 포함됩니다. 이번 장에서는 Pandas ‘Series’와 ’DataFrame’ 객체의 값에 접근하고 수정하는 방법을 살펴보겠습니다. NumPy 패턴에 익숙하다면 Pandas의 해당 패턴도 매우 친숙하게 느껴지겠지만, 주의해야 할 차이점이 몇 가지 있습니다.

먼저 1차원 Series 객체의 간단한 사례부터 시작하여, 더 복잡한 2차원 DataFrame 객체로 넘어가겠습니다.

Series 데이터 선택

이전 장에서 보았듯이 ‘Series’ 객체는 1차원 NumPy 배열과 비슷하면서도 표준 Python 딕셔너리와도 유사하게 작동합니다. 이 두 가지 특성을 염두에 두면 Series의 데이터 인덱싱 및 선택 패턴을 이해하기 쉽습니다.

Series를 딕셔너리로 사용

딕셔너리와 마찬가지로 ‘Series’ 객체는 키 컬렉션을 값 컬렉션에 매핑하는 기능을 제공합니다.

import pandas as pd

data = pd.Series([0.25, 0.5, 0.75, 1.0], index=["a", "b", "c", "d"])
data
a    0.25
b    0.50
c    0.75
d    1.00
dtype: float64
data["b"]
0.5

또한 딕셔너리처럼 Python 표현식과 메서드를 사용하여 키/인덱스와 값을 확인합니다.

"a" in data
True
data.keys()
Index(['a', 'b', 'c', 'd'], dtype='object')
list(data.items())
[('a', 0.25), ('b', 0.5), ('c', 0.75), ('d', 1.0)]

Series 객체는 딕셔너리 구문을 사용하여 수정합니다. 딕셔너리에 새 키를 할당하여 확장하듯, 새로운 인덱스 값에 값을 할당하여 ’Series’를 확장합니다.

data["e"] = 1.25
data
a    0.25
b    0.50
c    0.75
d    1.00
e    1.25
dtype: float64

이러한 객체의 높은 가변성(Mutability)은 매우 편리한 기능입니다. 내부적으로 Pandas는 메모리 레이아웃이나 데이터 복사 등에 관한 최적의 결정을 내리므로 사용자가 이를 일일이 신경 쓸 필요는 없습니다.

1차원 배열로서의 Series

’Series’는 딕셔너리 스타일의 인터페이스뿐만 아니라, NumPy 배열과 동일한 방식인 슬라이싱, 마스킹, 팬시 인덱싱을 통한 배열 스타일의 항목 선택 기능도 제공합니다. 다음은 그 예시입니다.

# 명시적 인덱스를 사용한 슬라이싱
data["a":"c"]
a    0.25
b    0.50
c    0.75
dtype: float64
# 암시적 정수 인덱스를 사용한 슬라이싱
data[0:2]
a    0.25
b    0.50
dtype: float64
# 마스킹
data[(data > 0.3) & (data < 0.8)]
b    0.50
c    0.75
dtype: float64
# 팬시 인덱싱
data[["a", "e"]]
a    0.25
e    1.25
dtype: float64

이 중 슬라이싱 방식이 가장 혼란스러울 수 있습니다. 명시적 인덱스로 슬라이싱할 때(예: data['a':'c'])는 마지막 인덱스가 슬라이스에 포함되지만, 암시적 인덱스로 슬라이싱할 때(예: data[0:2])는 마지막 인덱스가 슬라이스에서 제외됩니다.

인덱서: loc 및 iloc

Series에 명시적인 정수 인덱스가 있는 경우, data[1] 같은 인덱싱 작업은 명시적 인덱스를 사용하지만 data[1:3] 같은 슬라이싱 작업은 암시적인 Python 스타일 인덱스를 사용하게 됩니다.

data = pd.Series(["a", "b", "c"], index=[1, 3, 5])
data
1    a
3    b
5    c
dtype: object
# 인덱싱할 때 명시적 인덱스 사용
data[1]
'a'
# 슬라이싱할 때 암시적 인덱스 사용
data[1:3]
3    b
5    c
dtype: object

정수 인덱스를 사용할 때 발생할 수 있는 이러한 혼란을 방지하기 위해, Pandas는 특정 인덱싱 방식을 명시적으로 지정할 수 있는 특별한 indexer 속성을 제공합니다. 이는 함수 형태의 메서드가 아니라 ‘Series’ 데이터에 특정 슬라이싱 인터페이스를 노출하는 속성입니다.

첫 번째인 loc 속성은 항상 명시적 인덱스를 참조하여 인덱싱과 슬라이싱을 수행합니다.

data.loc[1]
'a'
data.loc[1:3]
1    a
3    b
dtype: object

iloc 속성은 항상 암시적인 Python 스타일 인덱스를 참조하여 인덱싱과 슬라이싱을 수행합니다.

data.iloc[1]
'b'
data.iloc[1:3]
3    b
5    c
dtype: object

Python 코드의 핵심 철학 중 하나는 “명시적인 것이 암시적인 것보다 낫다”는 것입니다. lociloc을 사용하여 의도를 명확히 하면 코드를 더 깔끔하고 읽기 쉽게 유지합니다. 특히 정수 인덱스를 다룰 때 인덱싱/슬라이싱 규칙이 섞여서 발생하는 미묘한 버그를 방지하는 데 큰 도움이 됩니다.

DataFrame에서 데이터 선택

DataFrame은 2차원 배열이나 구조화 배열처럼 작동하기도 하고, 한편으로는 인덱스를 공유하는 Series 객체들의 딕셔너리처럼 작동하기도 합니다. 이러한 관점들을 잘 이해하면 데이터 선택 기능을 익히는 데 도움이 됩니다.

딕셔너리로서의 DataFrame

첫 번째 관점은 DataFrame을 관련 Series 객체들의 딕셔너리로 보는 것입니다. 앞서 다룬 주의 면적과 인구 예제로 돌아가 보겠습니다.

area = pd.Series(
    {
        "California": 423967,
        "Texas": 695662,
        "Florida": 170312,
        "New York": 141297,
        "Pennsylvania": 119280,
    }
)
pop = pd.Series(
    {
        "California": 39538223,
        "Texas": 29145505,
        "Florida": 21538187,
        "New York": 20201249,
        "Pennsylvania": 13002700,
    }
)
data = pd.DataFrame({"area": area, "pop": pop})
data
area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187
New York 141297 20201249
Pennsylvania 119280 13002700

‘DataFrame’의 각 열을 구성하는 ’Series’ 객체는 열 이름을 키로 사용하는 딕셔너리 스타일 인덱싱으로 접근합니다.

data["area"]
California      423967
Texas           695662
Florida         170312
New York        141297
Pennsylvania    119280
Name: area, dtype: int64

마찬가지로, 열 이름이 문자열인 경우 속성(Attribute) 스타일로도 접근이 가능합니다.

data.area
California      423967
Texas           695662
Florida         170312
New York        141297
Pennsylvania    119280
Name: area, dtype: int64

이는 편리한 단축 표기법이지만, 모든 상황에 적용되는 것은 아닙니다. 예를 들어 열 이름이 문자열이 아니거나 ‘DataFrame’의 기존 메서드와 이름이 겹치는 경우에는 사용할 수 없습니다. DataFrame에는 이미 pop() 메서드가 있으므로, data.pop은 ’pop’ 열이 아니라 해당 메서드를 가리키게 됩니다.

data.pop is data["pop"]
False

특히 속성 스타일로 새로운 열을 할당하는 방식(예: data.pop = z)은 피해야 하며, 대신 data['pop'] = z와 같은 딕셔너리 스타일을 사용해야 합니다.

앞서 설명한 Series 객체처럼 딕셔너리 구문을 사용하여 객체를 수정할 수도 있습니다. 여기서는 새로운 열을 추가해 보겠습니다.

data["density"] = data["pop"] / data["area"]
data
area pop density
California 423967 39538223 93.257784
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
Pennsylvania 119280 13002700 109.009893

이는 Series 객체 간의 요소별 산술 연산 구문을 미리 보여준 것입니다. 자세한 내용은 Pandas 데이터 연산에서 다루겠습니다.

2차원 배열로서의 DataFrame

앞서 언급했듯 DataFrame은 확장된 2차원 배열로도 볼 수 있습니다. values 속성을 통해 가공되지 않은 기저 데이터를 배열 형태로 확인합니다.

data.values
array([[4.23967000e+05, 3.95382230e+07, 9.32577842e+01],
       [6.95662000e+05, 2.91455050e+07, 4.18960717e+01],
       [1.70312000e+05, 2.15381870e+07, 1.26463121e+02],
       [1.41297000e+05, 2.02012490e+07, 1.42970120e+02],
       [1.19280000e+05, 1.30027000e+07, 1.09009893e+02]])

이러한 특성 덕분에 DataFrame에서도 일반적인 배열 연산들을 수행합니다. 예를 들어 T 속성으로 DataFrame을 전치(Transpose)하여 행과 열을 바꿀 수 있습니다.

data.T
California Texas Florida New York Pennsylvania
area 4.239670e+05 6.956620e+05 1.703120e+05 1.412970e+05 1.192800e+05
pop 3.953822e+07 2.914550e+07 2.153819e+07 2.020125e+07 1.300270e+07
density 9.325778e+01 4.189607e+01 1.264631e+02 1.429701e+02 1.090099e+02

하지만 DataFrame 인덱싱에서는 주의가 필요합니다. 딕셔너리 스타일의 열 인덱싱이 우선되므로 단순히 NumPy 배열처럼 다룰 수는 없습니다. 특히 NumPy 배열에 단일 인덱스를 전달하면 해당 에 접근하게 됩니다.

data.values[0]
array([4.23967000e+05, 3.95382230e+07, 9.32577842e+01])

반면 DataFrame에 단일 인덱스를 전달하면 해당 에 접근합니다.

data["area"]
California      423967
Texas           695662
Florida         170312
New York        141297
Pennsylvania    119280
Name: area, dtype: int64

따라서 배열 스타일 인덱싱을 위해서는 다른 규칙이 필요하며, 이때 앞서 언급한 ‘loc’과 ’iloc’ 인덱서를 사용합니다. iloc 인덱서를 사용하면 마치 NumPy 배열을 다루듯 암시적인 인덱스로 기저 데이터를 인덱싱할 수 있으며, 결과에는 DataFrame의 행/열 레이블이 유지됩니다.

data.iloc[:3, :2]
area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187

마찬가지로 loc 인덱서를 사용하면 배열 스타일로 기저 데이터에 접근하되, 명시적인 인덱스와 열 이름을 사용합니다.

data.loc[:"Florida", :"pop"]
area pop
California 423967 39538223
Texas 695662 29145505
Florida 170312 21538187

이러한 인덱서 내에서는 익숙한 NumPy 스타일의 접근 패턴을 모두 사용합니다. 예를 들어 loc 인덱서에서 마스킹과 팬시 인덱싱을 다음과 같이 결합합니다.

data.loc[data.density > 120, ["pop", "density"]]
pop density
Florida 21538187 126.463121
New York 20201249 142.970120

인덱싱 규칙을 사용하여 값을 설정하거나 수정하는 것도 가능합니다. 이는 NumPy 배열에서 값을 변경하는 표준 방식과 동일합니다.

data.iloc[0, 2] = 90
data
area pop density
California 423967 39538223 90.000000
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120
Pennsylvania 119280 13002700 109.009893

Pandas 데이터 조작에 익숙해지려면 간단한 DataFrame으로 다양한 인덱싱 방식을 연습해 보는 것이 좋습니다. 인덱싱, 슬라이싱, 마스킹, 팬시 인덱싱 등을 직접 시도하며 차이점을 익혀보세요.

추가적인 인덱싱 규칙

앞선 논의와는 조금 충돌하는 듯 보이지만, 실무에서 꽤 유용하게 쓰이는 추가적인 인덱싱 규칙들이 있습니다. 먼저, 인덱싱은 열을 참조하는 반면, 슬라이싱은 행을 참조합니다.

data["Florida":"New York"]
area pop density
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120

슬라이싱을 할 때는 레이블 인덱스가 아닌 숫자로 행을 참조할 수도 있습니다.

data[1:3]
area pop density
Texas 695662 29145505 41.896072
Florida 170312 21538187 126.463121

마찬가지로 직접적인 마스킹 연산도 열이 아닌 행 단위로 해석됩니다.

data[data.density > 120]
area pop density
Florida 170312 21538187 126.463121
New York 141297 20201249 142.970120

이 두 규칙은 구문상 NumPy 배열과 비슷하며 Pandas의 일반적인 체계와 딱 떨어지지 않을 수 있지만, 실용적인 이점 때문에 포함되었습니다.