import pandas as pd
data = pd.Series([0.25, 0.5, 0.75, 1.0], index=["a", "b", "c", "d"])
dataa 0.25
b 0.50
c 0.75
d 1.00
dtype: float64
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’ 객체는 1차원 NumPy 배열과 비슷하면서도 표준 Python 딕셔너리와도 유사하게 작동합니다. 이 두 가지 특성을 염두에 두면 Series의 데이터 인덱싱 및 선택 패턴을 이해하기 쉽습니다.
딕셔너리와 마찬가지로 ‘Series’ 객체는 키 컬렉션을 값 컬렉션에 매핑하는 기능을 제공합니다.
import pandas as pd
data = pd.Series([0.25, 0.5, 0.75, 1.0], index=["a", "b", "c", "d"])
dataa 0.25
b 0.50
c 0.75
d 1.00
dtype: float64
data["b"]0.5
또한 딕셔너리처럼 Python 표현식과 메서드를 사용하여 키/인덱스와 값을 확인합니다.
"a" in dataTrue
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
dataa 0.25
b 0.50
c 0.75
d 1.00
e 1.25
dtype: float64
이러한 객체의 높은 가변성(Mutability)은 매우 편리한 기능입니다. 내부적으로 Pandas는 메모리 레이아웃이나 데이터 복사 등에 관한 최적의 결정을 내리므로 사용자가 이를 일일이 신경 쓸 필요는 없습니다.
’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])는 마지막 인덱스가 슬라이스에서 제외됩니다.
Series에 명시적인 정수 인덱스가 있는 경우, data[1] 같은 인덱싱 작업은 명시적 인덱스를 사용하지만 data[1:3] 같은 슬라이싱 작업은 암시적인 Python 스타일 인덱스를 사용하게 됩니다.
data = pd.Series(["a", "b", "c"], index=[1, 3, 5])
data1 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 코드의 핵심 철학 중 하나는 “명시적인 것이 암시적인 것보다 낫다”는 것입니다. loc과 iloc을 사용하여 의도를 명확히 하면 코드를 더 깔끔하고 읽기 쉽게 유지합니다. 특히 정수 인덱스를 다룰 때 인덱싱/슬라이싱 규칙이 섞여서 발생하는 미묘한 버그를 방지하는 데 큰 도움이 됩니다.
DataFrame은 2차원 배열이나 구조화 배열처럼 작동하기도 하고, 한편으로는 인덱스를 공유하는 Series 객체들의 딕셔너리처럼 작동하기도 합니다. 이러한 관점들을 잘 이해하면 데이터 선택 기능을 익히는 데 도움이 됩니다.
첫 번째 관점은 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.areaCalifornia 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 데이터 연산에서 다루겠습니다.
앞서 언급했듯 DataFrame은 확장된 2차원 배열로도 볼 수 있습니다. values 속성을 통해 가공되지 않은 기저 데이터를 배열 형태로 확인합니다.
data.valuesarray([[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의 일반적인 체계와 딱 떨어지지 않을 수 있지만, 실용적인 이점 때문에 포함되었습니다.