import pandas as pd
import numpy as np계층적 인덱싱
지금까지 우리는 주로 Pandas ‘Series’와 ’DataFrame’ 객체에 저장된 1차원 및 2차원 데이터를 다루었습니다. 하지만 실제로는 이를 넘어 고차원 데이터, 즉 3개 이상의 키로 인덱싱된 데이터를 저장해야 할 때가 많습니다. 초기 Pandas 버전은 2D DataFrame의 3D, 4D 버전인 Panel과 Panel4D 객체를 제공했지만, 실제로 사용하기에는 다소 번거로운 면이 있었습니다. 현재 고차원 데이터를 처리하는 훨씬 일반적인 패턴은 계층적 인덱싱(다중 인덱싱이라고도 함)을 사용하여 단일 인덱스 내에 여러 인덱스 수준(Level)을 통합하는 방식입니다. 이 방식을 사용하면 익숙한 1차원 Series와 2차원 DataFrame 객체 내에서도 고차원 데이터를 간결하게 표현합니다. (Pandas 스타일의 유연한 인덱스를 가진 진정한 N차원 배열이 필요하다면 Xarray 패키지를 참고해 보세요.)
이번 장에서는 MultiIndex 객체를 직접 생성하는 방법과 다중 인덱싱된 데이터의 인덱싱, 슬라이싱, 통계 계산 시 고려 사항을 살펴보겠습니다. 또한 일반적인 데이터 표현과 계층적 인덱스 표현 사이를 변환하는 유용한 방법들도 알아보겠습니다.
먼저 필요한 패키지들을 임포트하며 시작합니다.
다중 인덱싱된 Series
먼저 1차원 Series 내에서 2차원 데이터를 어떻게 표현할 수 있는지 생각해보겠습니다. 이해를 돕기 위해, 각 데이터 포인트가 문자와 숫자 키를 동시에 가지는 사례를 가정해 보겠습니다.
좋지 않은 방법
두 해(Year)에 걸쳐 여러 주의 데이터를 추적하고 싶다고 가정해 보겠습니다. 지금까지 배운 Pandas 도구만 활용한다면, 단순히 Python 튜플(Tuple)을 키로 사용하는 방식을 고려해볼 수 있습니다.
index = [('California', 2010), ('California', 2020),
('New York', 2010), ('New York', 2020),
('Texas', 2010), ('Texas', 2020)]
populations = [37253956, 39538223,
19378102, 20201249,
25145561, 29145505]
pop = pd.Series(populations, index=index)
pop(California, 2010) 37253956
(California, 2020) 39538223
(New York, 2010) 19378102
(New York, 2020) 20201249
(Texas, 2010) 25145561
(Texas, 2020) 29145505
dtype: int64
이러한 인덱싱 방식을 사용하면 튜플 인덱스를 기반으로 Series를 직접 인덱싱하거나 슬라이싱합니다.
pop[('California', 2020):('Texas', 2010)](California, 2020) 39538223
(New York, 2010) 19378102
(New York, 2020) 20201249
(Texas, 2010) 25145561
dtype: int64
하지만 편리함은 여기까지입니다. 예를 들어 2010년 데이터만 모두 선택해야 한다면, 이를 처리하기 위해 다소 복잡하고 비효율적인 연산을 수행해야 합니다.
pop[[i for i in pop.index if i[1] == 2010]](California, 2010) 37253956
(New York, 2010) 19378102
(Texas, 2010) 25145561
dtype: int64
원하는 결과를 얻기는 했지만, 평소 Pandas에서 사용하던 깔끔한 슬라이싱 구문에 비하면 복잡하고 데이터가 커질수록 성능도 떨어집니다.
더 나은 대안: Pandas MultiIndex
다행히 Pandas는 더 효율적인 방법을 제공합니다. 튜플 기반 인덱싱은 사실 다중 인덱스의 기초적인 형태이며, Pandas의 MultiIndex 타입이 바로 우리가 원하는 기능을 지원합니다. 다음과 같이 튜플 리스트로부터 다중 인덱스를 생성합니다.
index = pd.MultiIndex.from_tuples(index)MultiIndex는 여러 인덱스 수준(Levels)뿐만 아니라, 각 데이터 포인트가 어떤 수준에 속하는지 나타내는 여러 레이블(Labels)을 포함합니다.
이 MultiIndex를 사용하여 Series의 인덱스를 재설정하면 데이터의 계층적 구조를 확인합니다.
pop = pop.reindex(index)
popCalifornia 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
Series를 출력해보면 처음 두 열은 다중 인덱스 값을 보여주고, 세 번째 열은 실제 데이터를 보여줍니다. 첫 번째 열의 빈 항목은 바로 윗줄과 동일한 값을 의미합니다.
이제 두 번째 인덱스 수준이 2020인 모든 데이터에 접근하고 싶다면, Pandas의 간편한 슬라이싱 표기법을 사용합니다.
pop[:, 2020]California 39538223
New York 20201249
Texas 29145505
dtype: int64
그 결과 우리가 원하던 키만 포함된 단일 인덱스 Series가 반환됩니다. 이 방식은 처음에 시도했던 튜플 기반 방식보다 훨씬 편리하고 연산 효율도 매우 높습니다. 이제 이러한 계층적 인덱싱 데이터의 활용법을 좀 더 자세히 알아보겠습니다.
차원의 확장으로서의 MultiIndex
여기서 한 가지 사실을 알 수 있습니다. 사실 이 데이터는 행 인덱스와 열 레이블이 있는 일반적인 DataFrame으로도 충분히 저장합니다. 실제로 Pandas는 이러한 구조적 유사성을 염두에 두고 설계되었습니다. unstack() 메서드를 사용하면 다중 인덱싱된 Series를 일반적인 인덱스를 가진 DataFrame으로 빠르게 변환합니다.
pop_df = pop.unstack()
pop_df| 2010 | 2020 | |
|---|---|---|
| California | 37253956 | 39538223 |
| New York | 19378102 | 20201249 |
| Texas | 25145561 | 29145505 |
반대로 stack() 메서드를 사용하면 다시 원래의 형태로 되돌릴 수 있습니다.
pop_df.stack()California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
그렇다면 왜 굳이 계층적 인덱싱을 사용해야 할까요? 이유는 간단합니다. 다중 인덱싱을 사용하면 1차원 Series 안에 2차원 데이터를 담을 수 있는 것처럼, Series나 DataFrame에서 3차원 이상의 고차원 데이터도 자유롭게 다룰 수 있기 때문입니다. 다중 인덱스의 각 수준은 데이터의 새로운 차원을 나타냅니다. 이 특성을 활용하면 표현할 수 있는 데이터의 형태가 훨씬 다양해집니다. 예를 들어 매년 각 주에 대해 인구 총합뿐만 아니라 연령대별 인구(예: 18세 미만) 같은 추가 정보를 더하고 싶을 때, MultiIndex를 사용하면 DataFrame에 새 열을 추가하는 것만큼이나 간단하게 구현합니다.
pop_df = pd.DataFrame({'total': pop,
'under18': [9284094, 8898092,
4318033, 4181528,
6879014, 7432474]})
pop_df| total | under18 | ||
|---|---|---|---|
| California | 2010 | 37253956 | 9284094 |
| 2020 | 39538223 | 8898092 | |
| New York | 2010 | 19378102 | 4318033 |
| 2020 | 20201249 | 4181528 | |
| Texas | 2010 | 25145561 | 6879014 |
| 2020 | 29145505 | 7432474 |
또한 Pandas 데이터 연산에서 다룬 ufunc를 비롯한 모든 기능은 계층적 인덱스에서도 잘 작동합니다. 다음은 위 데이터를 활용해 연도별 18세 미만 인구 비율을 계산하는 예시입니다.
f_u18 = pop_df['under18'] / pop_df['total']
f_u18.unstack()| 2010 | 2020 | |
|---|---|---|
| California | 0.249211 | 0.225050 |
| New York | 0.222831 | 0.206994 |
| Texas | 0.273568 | 0.255013 |
이처럼 MultiIndex를 활용하면 고차원 데이터도 매우 빠르고 직관적으로 조작하고 탐색합니다.
MultiIndex 생성 방법
다중 인덱스 Series나 DataFrame을 만드는 가장 간단한 방법은 생성자에 두 개 이상의 인덱스 배열 리스트를 전달하는 것입니다. 예를 들면 다음과 같습니다.
df = pd.DataFrame(np.random.rand(4, 2),
index=[['a', 'a', 'b', 'b'], [1, 2, 1, 2]],
columns=['data1', 'data2'])
df| data1 | data2 | ||
|---|---|---|---|
| a | 1 | 0.748464 | 0.561409 |
| 2 | 0.379199 | 0.622461 | |
| b | 1 | 0.701679 | 0.687932 |
| 2 | 0.436200 | 0.950664 |
그러면 내부적으로 MultiIndex 생성 작업이 자동으로 수행됩니다.
마찬가지로 적절한 튜플을 키로 사용하는 딕셔너리를 전달하면, Pandas는 이를 인식하고 MultiIndex를 적용합니다.
data = {('California', 2010): 37253956,
('California', 2020): 39538223,
('New York', 2010): 19378102,
('New York', 2020): 20201249,
('Texas', 2010): 25145561,
('Texas', 2020): 29145505}
pd.Series(data)California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
하지만 상황에 따라서는 MultiIndex를 명시적으로 생성하는 것이 더 유용합니다. 그 방법들을 몇 가지 살펴보겠습니다.
명시적 MultiIndex 생성자
인덱스를 구성하는 방식을 좀 더 유연하게 제어하고 싶다면 pd.MultiIndex 클래스가 제공하는 메서드를 사용하면 됩니다. 예를 들어 앞서 했던 것처럼 각 수준의 인덱스 값을 담은 배열 리스트로부터 MultiIndex를 생성합니다.
pd.MultiIndex.from_arrays([['a', 'a', 'b', 'b'], [1, 2, 1, 2]])MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
또는 각 지점의 인덱스 쌍을 담은 튜플 리스트로부터 생성할 수도 있습니다.
pd.MultiIndex.from_tuples([('a', 1), ('a', 2), ('b', 1), ('b', 2)])MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
단일 인덱스들의 데카르트 곱(Cartesian Product)으로 구성하는 것도 가능합니다.
pd.MultiIndex.from_product([['a', 'b'], [1, 2]])MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
내부적인 인코딩 방식을 직접 다루고 싶다면 levels(각 수준에서 사용 가능한 인덱스 값 리스트)와 codes(이 값들을 참조하는 정수 리스트)를 전달하여 MultiIndex를 구성할 수도 있습니다.
pd.MultiIndex(levels=[['a', 'b'], [1, 2]],
codes=[[0, 0, 1, 1], [0, 1, 0, 1]])MultiIndex([('a', 1),
('a', 2),
('b', 1),
('b', 2)],
)
이렇게 생성한 객체들은 Series나 DataFrame을 만들 때 index 인자로 전달하거나, 기존 객체의 reindex() 메서드에 활용합니다.
MultiIndex 수준별 이름 지정
MultiIndex의 각 수준에 이름을 붙여두면 관리가 편리합니다. 앞서 설명한 생성자의 names 인자를 사용하거나, 이미 생성된 인덱스의 names 속성을 설정하여 이름을 지정합니다.
pop.index.names = ['state', 'year']
popstate year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
데이터셋이 복잡해질수록 각 인덱스의 의미를 직관적으로 파악하는 데 큰 도움이 됩니다.
열(Column)에 대한 MultiIndex
DataFrame에서는 행과 열이 대칭 구조이므로, 행뿐만 아니라 열에도 여러 수준의 인덱스를 가질 수 있습니다. 다음 의료 데이터 모의 예제를 살펴보겠습니다.
# 계층적 인덱스와 열 생성
index = pd.MultiIndex.from_product([[2013, 2014], [1, 2]],
names=['year', 'visit'])
columns = pd.MultiIndex.from_product([['Bob', 'Guido', 'Sue'], ['HR', 'Temp']],
names=['subject', 'type'])
# 모의 데이터 생성
data = np.round(np.random.randn(4, 6), 1)
data[:, ::2] *= 10
data += 37
# DataFrame 생성
health_data = pd.DataFrame(data, index=index, columns=columns)
health_data| subject | Bob | Guido | Sue | ||||
|---|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp | |
| year | visit | ||||||
| 2013 | 1 | 30.0 | 38.0 | 56.0 | 38.3 | 45.0 | 35.8 |
| 2 | 47.0 | 37.1 | 27.0 | 36.0 | 37.0 | 36.4 | |
| 2014 | 1 | 51.0 | 35.9 | 24.0 | 36.7 | 32.0 | 36.2 |
| 2 | 49.0 | 36.3 | 48.0 | 39.2 | 31.0 | 35.7 | |
이는 검사 대상, 측정 항목, 연도, 방문 횟수라는 4개의 차원을 가진 데이터입니다. 예를 들어 사람 이름으로 최상위 열을 인덱싱하면, 해당 인물의 정보만 포함된 전체 DataFrame을 쉽게 얻을 수 있습니다.
health_data['Guido']| type | HR | Temp | |
|---|---|---|---|
| year | visit | ||
| 2013 | 1 | 56.0 | 38.3 |
| 2 | 27.0 | 36.0 | |
| 2014 | 1 | 24.0 | 36.7 |
| 2 | 48.0 | 39.2 |
MultiIndex 인덱싱 및 슬라이싱
MultiIndex의 인덱싱과 슬라이싱은 매우 직관적으로 설계되어 있습니다. 인덱스를 단순한 데이터의 추가 차원으로 생각하면 이해가 쉽습니다. 먼저 다중 인덱싱된 Series부터 살펴본 다음, DataFrame 객체의 활용법을 알아보겠습니다.
다중 인덱싱된 Series
앞서 살펴본 주별 인구 데이터를 담은 다중 인덱스 Series를 다시 예로 들어보겠습니다.
popstate year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
여러 인덱스 값을 순차적으로 지정하여 특정 요소에 접근합니다.
pop['California', 2010]37253956
MultiIndex는 일부 인덱스 수준만 지정하여 데이터를 선택하는 부분 인덱싱도 지원합니다. 이 경우 하위 수준의 인덱스가 유지된 형태의 새로운 Series가 반환됩니다.
pop['California']year
2010 37253956
2020 39538223
dtype: int64
MultiIndex가 정렬되어 있다면 부분 슬라이싱도 가능합니다.(자세한 내용은 정렬된 인덱스와 정렬되지 않은 인덱스 참고)
pop.loc['California':'New York']state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
dtype: int64
인덱스가 정렬되어 있으면 첫 번째 인덱스 수준에 빈 슬라이스를 전달하여, 하위 수준에서만 부분 인덱싱을 수행할 수도 있습니다.
pop[:, 2010]state
California 37253956
New York 19378102
Texas 25145561
dtype: int64
데이터 인덱싱 및 선택에서 살펴본 다른 인덱싱 방식들도 모두 잘 작동합니다. 예를 들어 부울 마스크를 활용한 선택은 다음과 같습니다.
pop[pop > 22000000]state year
California 2010 37253956
2020 39538223
Texas 2010 25145561
2020 29145505
dtype: int64
물론 팬시 인덱싱(Fancy Indexing)도 가능합니다.
pop[['California', 'Texas']]state year
California 2010 37253956
2020 39538223
Texas 2010 25145561
2020 29145505
dtype: int64
다중 인덱싱된 DataFrame
다중 인덱스 DataFrame도 유사한 방식으로 동작합니다. 앞서 만든 의료 데이터 예제를 다시 활용해 보겠습니다.
health_data| subject | Bob | Guido | Sue | ||||
|---|---|---|---|---|---|---|---|
| type | HR | Temp | HR | Temp | HR | Temp | |
| year | visit | ||||||
| 2013 | 1 | 30.0 | 38.0 | 56.0 | 38.3 | 45.0 | 35.8 |
| 2 | 47.0 | 37.1 | 27.0 | 36.0 | 37.0 | 36.4 | |
| 2014 | 1 | 51.0 | 35.9 | 24.0 | 36.7 | 32.0 | 36.2 |
| 2 | 49.0 | 36.3 | 48.0 | 39.2 | 31.0 | 35.7 | |
다중 인덱스 Series에서 사용했던 구문은 DataFrame의 열(Column)에도 똑같이 적용됩니다. 예를 들어 Guido의 심박수 데이터를 추출하는 방법은 다음과 같습니다.
health_data['Guido', 'HR']year visit
2013 1 56.0
2 27.0
2014 1 24.0
2 48.0
Name: (Guido, HR), dtype: float64
또한 단일 인덱스 때와 마찬가지로 loc이나 iloc 인덱서도 사용합니다. (데이터 인덱싱 및 선택 참고)
health_data.iloc[:2, :2]| subject | Bob | ||
|---|---|---|---|
| type | HR | Temp | |
| year | visit | ||
| 2013 | 1 | 30.0 | 38.0 |
| 2 | 47.0 | 37.1 | |
이러한 인덱서들은 기저의 2차원 데이터에 대해 배열과 같은 관점을 제공합니다. 다만 loc이나 iloc의 각 인덱스 자리에 여러 수준의 인덱스를 담은 튜플을 전달할 수 있다는 점이 특징입니다.
health_data.loc[:, ('Bob', 'HR')]year visit
2013 1 30.0
2 47.0
2014 1 51.0
2 49.0
Name: (Bob, HR), dtype: float64
인덱스 튜플 안에서 직접 슬라이싱을 시도하는 것은 다소 까다롭습니다. 튜플 내부에 슬라이스 기호(:)를 사용하면 구문 오류가 발생합니다.
idx = pd.IndexSlice
health_data.loc[idx[:, 1], idx[:, 'HR']]File "/var/folders/xc/sptt9bk14s34rgxt7453p03r0000gp/T/ipykernel_86488/3311942670.py", line 1 health_data.loc[(:, 1), (:, 'HR')] ^ SyntaxError: invalid syntax
Python 내장 함수인 slice()를 사용하여 슬라이스를 명시적으로 생성해 해결할 수도 있지만, 더 나은 방법은 Pandas가 제공하는 IndexSlice 객체를 사용하는 것입니다. 다음은 그 활용 예시입니다.
idx = pd.IndexSlice
health_data.loc[idx[:, 1], idx[:, 'HR']]| subject | Bob | Guido | Sue | |
|---|---|---|---|---|
| type | HR | HR | HR | |
| year | visit | |||
| 2013 | 1 | 30.0 | 56.0 | 45.0 |
| 2014 | 1 | 51.0 | 24.0 | 32.0 |
보시다시피 다중 인덱싱된 Series와 DataFrame 데이터를 다루는 방법은 매우 다양합니다. 이러한 도구들에 익숙해지는 가장 좋은 방법은 직접 코드를 작성하며 시도해 보는 것입니다!
다중 인덱스 데이터 재구조화
다중 인덱스 데이터 작업의 핵심 중 하나는 데이터를 목적에 맞게 효과적으로 변환하는 것입니다. 데이터셋의 모든 정보를 보존하면서도 계산 방식이나 시각화 목적에 따라 형태를 바꾸는 여러 연산들이 있습니다. 앞서 stack()과 unstack() 메서드를 간단히 살펴보았는데, 여기서는 계층적 인덱스와 열 사이의 데이터 배치를 더욱 세밀하게 제어하는 방법들을 알아보겠습니다.
정렬된 인덱스와 정렬되지 않은 인덱스
앞서 잠깐 언급했지만, 여기서 한 번 더 강조하고 넘어가겠습니다. 인덱스가 정렬되어 있지 않으면 대부분의 ‘MultiIndex’ 슬라이싱 연산에서 오류가 발생합니다. 구체적인 사례를 통해 확인해 보겠습니다.
인덱스가 사전순으로 정렬되지 않은 간단한 다중 인덱스 데이터를 만들어보겠습니다.
index = pd.MultiIndex.from_product([['a', 'c', 'b'], [1, 2]])
data = pd.Series(np.random.rand(6), index=index)
data.index.names = ['char', 'int']
datachar int
a 1 0.280341
2 0.097290
c 1 0.206217
2 0.431771
b 1 0.100183
2 0.015851
dtype: float64
이 상태에서 슬라이싱을 시도하면 에러가 발생합니다.
try:
data['a':'b']
except KeyError as e:
print("KeyError", e)KeyError 'Key length (1) was greater than MultiIndex lexsort depth (0)'
에러 메시지만 봐서는 원인이 명확하지 않을 수 있지만, 이는 MultiIndex가 정렬되지 않았기 때문에 발생하는 문제입니다. 부분 슬라이싱 같은 연산이 제대로 작동하려면 MultiIndex의 각 수준이 사전순으로 정렬되어 있어야 합니다. Pandas는 이러한 정렬을 위해 DataFrame의 sort_index() 같은 편리한 도구를 제공합니다. 여기서는 가장 일반적인 sort_index()를 사용해 보겠습니다.
data = data.sort_index()
datachar int
a 1 0.280341
2 0.097290
b 1 0.100183
2 0.015851
c 1 0.206217
2 0.431771
dtype: float64
인덱스를 정렬한 후에는 부분 슬라이싱이 예상대로 잘 작동합니다.
data['a':'b']char int
a 1 0.280341
2 0.097290
b 1 0.100183
2 0.015851
dtype: float64
인덱스 스태킹 및 언스태킹
앞서 간단히 보았듯, 다중 인덱스 데이터를 일반적인 2차원 표현으로 변환합니다. 이때 어떤 수준(Level)을 사용할지 직접 지정하는 것도 가능합니다.
pop.unstack(level=0)| state | California | New York | Texas |
|---|---|---|---|
| year | |||
| 2010 | 37253956 | 19378102 | 25145561 |
| 2020 | 39538223 | 20201249 | 29145505 |
pop.unstack(level=1)| year | 2010 | 2020 |
|---|---|---|
| state | ||
| California | 37253956 | 39538223 |
| New York | 19378102 | 20201249 |
| Texas | 25145561 | 29145505 |
unstack()의 반대 연산은 stack()입니다. 여기서는 원래의 Series 형태로 되돌리는 데 사용합니다.
pop.unstack().stack()state year
California 2010 37253956
2020 39538223
New York 2010 19378102
2020 20201249
Texas 2010 25145561
2020 29145505
dtype: int64
인덱스 설정 및 재설정
계층적 데이터를 재배열하는 또 다른 유용한 방법은 인덱스 레이블을 일반적인 열로 변환하는 것입니다. 이는 reset_index() 메서드로 수행합니다. 인구 데이터 Series에서 이 메서드를 호출하면, 기존 인덱스 정보가 state와 year 열로 옮겨진 DataFrame이 생성됩니다. 필요한 경우 결과물에 나타낼 데이터 열의 이름을 직접 지정할 수도 있습니다.
pop_flat = pop.reset_index(name='population')
pop_flat| state | year | population | |
|---|---|---|---|
| 0 | California | 2010 | 37253956 |
| 1 | California | 2020 | 39538223 |
| 2 | New York | 2010 | 19378102 |
| 3 | New York | 2020 | 20201249 |
| 4 | Texas | 2010 | 25145561 |
| 5 | Texas | 2020 | 29145505 |
반대로 열 데이터로부터 MultiIndex를 구성하는 패턴도 흔히 쓰입니다. 이때는 set_index() 메서드를 사용하여 다중 인덱싱된 DataFrame을 얻을 수 있습니다.
pop_flat.set_index(['state', 'year'])| population | ||
|---|---|---|
| state | year | |
| California | 2010 | 37253956 |
| 2020 | 39538223 | |
| New York | 2010 | 19378102 |
| 2020 | 20201249 | |
| Texas | 2010 | 25145561 |
| 2020 | 29145505 |
이러한 형태의 인덱스 재구성은 실제 데이터셋을 탐색하고 분석할 때 가장 자주 활용되는 유용한 패턴 중 하나입니다.