중첩된 데이터 (Nested Data)

서론

이 장에서는 중첩된 데이터(nested data)에 대해 배웁니다. 즉, 근본적으로 트리(tree)와 같은 구조를 가진 데이터를 다루고, 이를 행과 열로 구성된 직사각형 데이터 프레임으로 변환하는(종종) 법을 배웁니다. 이는 특히 웹 API에서 오는 데이터를 다룰 때 중첩된 데이터가 놀라울 정도로 흔하기 때문에 중요합니다(웹 스크래핑과 API (Webscraping and APIs) 참조).

사각형 데이터로 만드는 법(rectangling)을 배우기 위해, 먼저 리스트(lists), 딕셔너리(dictionaries), 그리고 JSON 포맷에 대해 배울 것입니다. 이들은 파이썬에서 계층적 데이터를 다루는 데 가장 자주 사용되는 데이터 구조이기 때문입니다. 그런 다음 계층적 데이터를 열과 행이 있는 ‘깔끔한’ 데이터로 바꾸는 데 도움이 되는 몇 가지 함수를 배울 것입니다. 그 후 실제 복잡한 문제를 해결하기 위해 이러한 간단한 함수를 여러 번 적용하는 몇 가지 사례 연구를 보여드리겠습니다.

사전 준비

이 장에서는 pandas 데이터 분석 패키지를 사용합니다.

리스트 (Lists)

리스트는 많은 데이터를 한꺼번에 다루는 데 매우 유용한 방법입니다. 대괄호로 정의하며, 각 항목은 쉼표로 구분합니다.

코드 보기
list_example = [10, 1.23, "이런 식", True, None]
print(list_example)
[10, 1.23, '이런 식', True, None]

항목을 추가(append)하여 리스트를 구성할 수도 있습니다:

코드 보기
list_example.append("항목 하나 더")
print(list_example)
[10, 1.23, '이런 식', True, None, '항목 하나 더']

그리고 인덱스를 사용하여 각 항목에 접근할 수 있습니다. 인덱스는 0부터 시작하여 리스트 길이보다 하나 작은 수로 끝납니다(이는 많은 프로그래밍 언어의 관례입니다). 예를 들어, 0을 사용하여 시작 부분의 특정 항목을 출력하고 -1을 사용하여 끝부분의 항목을 출력해 보겠습니다:

코드 보기
print(list_example[0])
print(list_example[-1])
10
항목 하나 더

{.callout-note} 연습 문제 리스트 객체에 요소가 몇 개 있는지 모를 때, 뒤에서 두 번째 항목에 어떻게 접근할 수 있을까요?

인덱싱을 사용하여 리스트의 위치에 접근하는 것뿐만 아니라, 리스트에 슬라이스(slices)를 사용할 수도 있습니다. 콜론 문자 :를 사용하여 ‘처음부터’ 또는 ‘끝까지’(한 번만 나타날 때)를 나타냅니다. 예를 들어, 마지막 두 항목만 출력하려면 -2: 인덱스를 사용하여 ’뒤에서 두 번째부터 끝까지’를 의미하게 합니다. 다음은 두 가지 별개의 예시입니다: 처음 세 개와 마지막 세 개의 항목을 차례대로 출력합니다:

코드 보기
print(list_example[:3])
print(list_example[-3:])
[10, 1.23, '이런 식']
[True, None, '항목 하나 더']

두 번째 콜론을 사용하여 항목을 건너뛸 수도 있으므로 슬라이싱은 이보다 더 정교해질 수 있습니다. 다음은 두 번째 항목(인덱스는 0부터 시작함을 기억하세요)에서 시작하여 뒤에서 두 번째 항목 앞(미포함)까지 진행하며, 그사이의 매 두 번째 항목마다 건너뛰는 전체 예제입니다(range는 단지 해당 값부터 마지막 값보다 하나 작은 값까지의 정수 리스트를 생성합니다):

코드 보기
list_of_numbers = list(range(1, 11))
start = 1
stop = -1
step = 2
print(list_of_numbers[start:stop:step])
[2, 4, 6, 8]

유용한 팁은 더블 콜론을 사용하여 리스트를 완전히 뒤집어서 출력할 수 있다는 점입니다:

코드 보기
print(list_of_numbers[::-1])
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]

연습 문제

앞서 만든 list_example을 슬라이싱하여 처음 다섯 개 항목만 가져와 보세요.

리스트의 놀라운 점은 다른 리스트를 포함하여 어떤 타입이든 담을 수 있다는 것입니다! 다음은 많은 것이 들어 있는 유효한 리스트 예제입니다:

코드 보기
wacky_list = [
    3.1415,
    16,
    ["five", 4, 3],
    (91, 93, 90),
    "Hello World!",
    True,
    None,
    {"key": "value", "key2": "value2"},
]
wacky_list
[3.1415,
 16,
 ['five', 4, 3],
 (91, 93, 90),
 'Hello World!',
 True,
 None,
 {'key': 'value', 'key2': 'value2'}]

리스트에서의 계층적 데이터

리스트는 다른 리스트를 포함할 수 있기 때문에(계속해서), 계층적인 데이터를 넣는 데 사용될 수 있습니다. 예제를 살펴보겠습니다:

코드 보기
multilayer_list = [[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]
multilayer_list
[[1, 2, 3, 4, 5], [6, 7, 8, 9, 10], [11, 12, 13, 14, 15]]

이제 이 리스트를 단일 리스트로 축소하고 싶다고 가정해 봅시다. 리스트 컴프리헨션으로 할 수 있습니다:

코드 보기
[x for little_list in multilayer_list for x in little_list]
[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]

여기서 말하는 것은 모든 작은 리스트의 모든 값을 가져와서 하나의 리스트에 넣으라는 것입니다.

리스트에서 데이터 프레임으로

가끔 리스트에 있는 데이터를 데이터 프레임으로 바꾸고 싶을 때가 있을 것입니다. 예를 들어 다음과 같은 리스트들의 리스트가 있다고 해봅시다:

코드 보기
list_of_lists = [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10, 11, 12]]

이것을 데이터 프레임 생성자의 data= 키워드 인수로 바로 전달할 수 있습니다(필요한 다른 정보도 추가하여). 이것은 3개의 항목을 가진 4개의 리스트이므로, 내부 루프는 0에서 2까지의 항목을 가집니다… 데이터 프레임의 으로 사용되는 것은 이 내부 루프이며, 각 내부 리스트의 항목 수는 의 수와 같습니다.

코드 보기
import pandas as pd

pd.DataFrame(data=list_of_lists, columns=["a", "b", "c"])
a b c
0 1 2 3
1 4 5 6
2 7 8 9
3 10 11 12

보여드릴 기술이 하나 더 있습니다: explode(폭발시키기). 이는 리스트 깊이가 한 단계 이상인 데이터를 다룰 때 유용합니다. 다음과 같이 복잡한 계층 구조를 가진 데이터를 읽어왔다고 가정해 봅시다:

코드 보기
df = pd.DataFrame(
    {
        "alpha": [[0, 1, 2], "foo", [], [3, 4]],
        "beta": 1,
        "gamma": [["a", "b", "c"], pd.NA, [], ["d", "e"]],
    }
)
df
alpha beta gamma
0 [0, 1, 2] 1 [a, b, c]
1 foo 1 <NA>
2 [] 1 []
3 [3, 4] 1 [d, e]

리스트를 포함하는 여러 행과 열이 있습니다. 어떤 상황에서는 열에 리스트가 있어도 괜찮지만, 여기서는 다른 타입의 데이터와 섞여 있어 좋지 않습니다. explode()를 사용하여 열을 길이 방향으로 더 분리할 수 있습니다.

코드 보기
df.explode("alpha")
alpha beta gamma
0 0 1 [a, b, c]
0 1 1 [a, b, c]
0 2 1 [a, b, c]
1 foo 1 <NA>
2 NaN 1 []
3 3 1 [d, e]
3 4 1 [d, e]

JSON (Java Script Object Notation)

프로그래밍과 계층적 데이터 객체에 대한 논의에서 JSON(Java Script Object Notation)을 빼놓을 수는 없습니다! 웹에서 데이터를 다룰 때, 특히 API(자동화된 웹 기반 데이터 서비스)에서 오는 데이터를 다룰 때 항상 이 텍스트 데이터 포맷을 마주하게 될 것입니다. JSON은 파이썬 딕셔너리(형식은 {key1: value1, key2: value2})와 마찬가지로 객체를 이름/값 쌍으로 표현합니다.

아래 표는 JSON과 파이썬에서 발견되는 서로 다른 데이터 타입을 비교합니다.

JSON 객체 파이썬 객체
object dict
array list
string str
null None
number (int) int
number (real) float
true True
false False

일반적으로 JSON 데이터로 수행하고 싶은 작업은 두 가지입니다: 1) JSON 데이터를 파이썬 객체로 바꾸거나(예: JSON을 파이썬 딕셔너리로), 그 반대(각각 역직렬화 및 직렬화라고 함), 2) 역직렬화된 객체를 다른 종류의 파이썬 객체로 변환하는 것입니다.

각각 차례대로 살펴보겠습니다.

JSON 데이터 읽어오기

JSON 데이터를 읽어오는 일반적인 예제를 살펴보겠습니다.

웹에서 가져오기

API로부터 JSON 데이터를 가져오겠습니다. 최신 영국 실업 데이터를 가져와 봅시다(시계열 코드 “MGSX”, 데이터셋 코드 “LMS”).

코드 보기
import requests

url = "https://api.beta.ons.gov.uk/v1/data?uri=employmentandlabourmarket/peoplenotinwork/unemployment/timeseries/mgsx/lms/previous/v106"

# ONS API로부터 데이터 가져오기:
json_data = requests.get(url).json()

어떤 타입을 얻었는지 확인해 봅시다:

코드 보기
type(json_data)
dict

예상대로 JSON 데이터는 자동으로 파이썬 딕셔너리로 읽혔습니다. 하지만 필드들이 숫자, datetime, 기타 특정 데이터 타입이 아닌 텍스트로 읽혔을 수 있으니 주의해야 합니다.

객체 전체를 출력할 수도 있지만 공간을 너무 많이 차지할 것입니다. 대신 “months” 키 아래에 있는 항목 두 개만 살펴보겠습니다.

코드 보기
json_data["months"][:2]
[{'date': '1971 FEB',
  'value': '3.8',
  'label': '1971 JAN-MAR',
  'year': '1971',
  'month': 'February',
  'quarter': '',
  'sourceDataset': 'LMS',
  'updateDate': '2015-10-13T23:00:00.000Z'},
 {'date': '1971 MAR',
  'value': '3.9',
  'label': '1971 FEB-APR',
  'year': '1971',
  'month': 'March',
  'quarter': '',
  'sourceDataset': 'LMS',
  'updateDate': '2015-10-13T23:00:00.000Z'}]

파일이나 스트림에서 가져오기

이 연습을 위해, 이 책과 연동된 저장소의 data 폴더에서 ‘cakes.json’ 파일을 다운로드하여 “data”라는 하위 폴더에 저장해야 합니다. 터미널을 사용하여 데이터를 살짝 엿볼 수 있습니다(앞의 느낌표는 이를 의미합니다):

코드 보기
json_string = """
{
 "food": "doughnut",
 "good_with": ["coffee", "tea"],
 "flavour": null,
 "toppings": [{"id": 0, "type": "glazed"},
              {"id": 1, "type": "sugar"}]
}
"""

내장된 json 라이브러리를 사용하여 이를 파이썬으로 읽어옵니다(여기서 파일 경로를 사용할 수도 있습니다 - 방법은 잠시 후에):

코드 보기
import json

result = json.loads(json_string)
result
{'food': 'doughnut',
 'good_with': ['coffee', 'tea'],
 'flavour': None,
 'toppings': [{'id': 0, 'type': 'glazed'}, {'id': 1, 'type': 'sugar'}]}

JSON 텍스트에서 파이썬 딕셔너리로 갈 때 모든 것이 똑같은 것은 아닙니다: JSON은 None 대신 null을 사용하고, 리스트 끝에 붙는 쉼표를 허용하지 않으며, 기본 타입으로 리스트, 문자열(모든 키는 문자열이어야 함), 숫자, 불리언, null을 가집니다. 이제 파이썬 딕셔너리를 다시 JSON으로 쓰는 법을 보겠습니다. 아마 파일로 저장할 때 필요하겠죠:

코드 보기
json_stream = json.dumps(result)
json_stream
'{"food": "doughnut", "good_with": ["coffee", "tea"], "flavour": null, "toppings": [{"id": 0, "type": "glazed"}, {"id": 1, "type": "sugar"}]}'

파일로 쓰려면 다음과 같은 패턴을 사용합니다:

with open('data/json_data_output.json', 'w') as outfile:
    json.dump(json_stream, outfile)

디스크에서 파일(예: “data/json_data_output.json”)을 읽으려면 다음과 같이 합니다.

json.load(open("data/json_data_output.json"))

JSON 데이터에서 데이터 프레임으로

pandas에는 JSON이나 딕셔너리 데이터를 데이터 프레임으로 바꾸는 많은 옵션이 있습니다. 다만 기저에 있는 데이터 구조에 대해 조금 생각할 필요가 있습니다:

코드 보기
import pandas as pd

pd.DataFrame(result["toppings"], columns=["id", "type"])
id type
0 0 glazed
1 1 sugar

앞서 다운로드한 웹 스크래핑 데이터는 더 복잡한 구조를 가지고 있었지만, pandas에는 이를 처리할 수 있는 json_normalize() 함수가 있습니다. 예를 들어 다음과 같은 데이터에는 많은 누락된 항목이 있지만 json_normalize()는 이를 데이터 프레임으로 파싱할 수 있습니다.

코드 보기
data = [
    {"id": 1, "name": {"first": "Coleen", "last": "Volk"}},
    {"name": {"given": "Mark", "family": "Regner"}},
    {"id": 2, "name": "Faye Raker"},
]
pd.json_normalize(data)
id name.first name.last name.given name.family name
0 1.0 Coleen Volk NaN NaN NaN
1 NaN NaN NaN Mark Regner NaN
2 2.0 NaN NaN NaN NaN Faye Raker

또한 ’name’과 같은 속성이 분리되는 레벨도 제어할 수 있습니다 (더 많은 옵션은 pandas 문서에서 확인하세요).

코드 보기
pd.json_normalize(data, max_level=0)
id name
0 1.0 {'first': 'Coleen', 'last': 'Volk'}
1 NaN {'given': 'Mark', 'family': 'Regner'}
2 2.0 Faye Raker

JSON 정규화(normalize) 함수뿐만 아니라, pandas에는 더 간단한 딕셔너리 객체 작업을 위한 from_dict() 메서드도 있습니다.