깔끔한 데이터 (Tidy Data)

서론

이 장에서는 깔끔한 데이터(tidy data) 원칙을 사용하여 파이썬에서 데이터를 구성하는 일관된 방법을 배웁니다. 깔끔한 데이터가 모든 경우에 적합한 것은 아니지만, 많은 분석과 표 형태의 데이터에서는 이것이 필요할 것입니다. 데이터를 이 형식으로 만드는 데는 초기에 노력이 필요하지만, 장기적으로는 그 노력이 보답을 해줍니다. 일단 데이터를 깔끔하게 만드는 데 성공하면, 한 표현 방식에서 다른 방식으로 데이터를 변환하는 데 드는 시간을 훨씬 줄일 수 있어, 여러분이 관심을 두는 데이터 질문에 더 많은 시간을 할애할 수 있게 됩니다.

이 장에서는 먼저 깔끔한 데이터의 정의를 배우고 간단한 장난감 데이터셋에 적용해 봅니다. 그런 다음 데이터를 깔끔하게 만드는 데 사용할 주요 도구인 ’녹이기(melting)’에 대해 깊이 알아봅니다. 녹이기를 사용하면 값은 변경하지 않으면서 데이터의 형태를 바꿀 수 있습니다. 마지막으로는 유용하게 깔끔하지 않은 데이터와 필요한 경우 이를 만드는 방법에 대한 논의로 마무리하겠습니다.

이 장이 특히 흥미롭고 기본 이론에 대해 더 자세히 알고 싶다면, Journal of Statistical Software에 게재된 Tidy Data 논문을 읽어보시길 권장합니다.

사전 준비

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

깔끔한 데이터 (Tidy Data)

데이터셋을 깔끔하게 만드는 세 가지 상호 연관된 특징이 있습니다:

  1. 각 변수는 하나의 열(column)이어야 합니다. 각 열은 하나의 변수입니다.
  2. 각 관측치는 하나의 행(row)이어야 합니다. 각 행은 하나의 관측치입니다.
  3. 각 값은 하나의 셀(cell)이어야 합니다. 각 셀은 하나의 단일 값입니다.

아래 그림은 이를 보여줍니다:

왜 데이터를 깔끔하게 유지해야 할까요? 두 가지 주요 장점이 있습니다:

  1. 데이터를 저장하는 일관된 방법 하나를 선택하는 데 따르는 일반적인 이점이 있습니다. 일관된 데이터 구조를 가지면 이를 다루는 도구들이 기저의 통일성을 가지고 있기 때문에 해당 도구들을 배우기가 더 쉽습니다. 예를 들어 데이터 시각화 패키지인 seaborn과 같은 일부 도구들은 깔끔한 데이터를 염두에 두고 설계되었습니다.

  2. 변수를 열에 배치하는 구체적인 이점은 pandas의 벡터화된 연산(더 효율적인 연산)을 활용할 수 있게 해준다는 점입니다.

깔끔한 데이터가 모든 상황과 모든 사례에 적합한 것은 아니지만, 표 형태의 데이터에 대해서는 매우 훌륭한 기본값입니다. 이를 기본값으로 사용하면 이후의 작업을 어떻게 수행할지 생각하기가 더 쉬워집니다.

깔끔한 데이터가 훌륭하다고 말씀드렸지만, 다른 데이터 분석 라이브러리와 비교했을 때 pandas의 장점 중 하나는 깔끔한 데이터에 너무 얽매이지 않고 다루기 까다로운 비정형 데이터 조작 작업도 수월하게 처리할 수 있다는 점입니다.

불러온 데이터가 깔끔하지 않게 만드는 두 가지 흔한 문제가 있습니다:

  1. 하나의 변수가 여러 열에 걸쳐 흩어져 있는 경우입니다.
  2. 하나의 관측치가 여러 행에 걸쳐 흩어져 있는 경우입니다.

전자의 경우, 여러 열로 된 ‘넓은(wide)’ 데이터를 ‘긴(long)’ 데이터로 “녹여야(melt)” 합니다.

후자의 경우, 여러 행을 열로 ‘풀어내거나(unstack)’ 피벗(pivot)해야 합니다 (즉, 긴 형태에서 넓은 형태로 가야 합니다).

아래에서 두 가지 모두 살펴보겠습니다.

pandas로 데이터를 깔끔하게 만드는 도구들

녹이기 (Melt)

melt()는 “넓은” 데이터에서 “긴” 데이터로 가는 데 도움을 주며, 꼭 기억해 두면 좋은 기능입니다.

실제로 작동하는 예제입니다:

코드 보기
import pandas as pd

df = pd.DataFrame(
    {
        "first": ["John", "Mary"],
        "last": ["Doe", "Bo"],
        "job": ["Nurse", "Economist"],
        "height": [5.5, 6.0],
        "weight": [130, 150],
    }
)
print("\n 녹이기 전 (Unmelted): ")
print(df)
print("\n 녹인 후 (Melted): ")
df.melt(id_vars=["first", "last"], var_name="quantity", value_vars=["height", "weight"])

 녹이기 전 (Unmelted): 
  first last        job  height  weight
0  John  Doe      Nurse     5.5     130
1  Mary   Bo  Economist     6.0     150

 녹인 후 (Melted): 
first last quantity value
0 John Doe height 5.5
1 Mary Bo height 6.0
2 John Doe weight 130.0
3 Mary Bo weight 150.0

{.callout-note} 연습 문제 `first`와 `last` 대신 `job`을 id로 사용하는 `melt()`를 수행해 보세요.

이것이 깔끔한 데이터와 어떤 관련이 있을까요? 때로는 여러 열에 걸쳐 있는 변수를 깔끔하게 정리하고 싶을 때가 있습니다. 세계보건기구(WHO)의 결핵 사례를 사용하는 이 예제를 살펴보겠습니다.

먼저 데이터를 열고 파일의 상단을 확인해 봅니다.

코드 보기
df_tb = pd.read_parquet("data/who_tb_cases.parquet")
df_tb.head()
country 1999 2000
0 Afghanistan 745.0 2666.0
1 Brazil 37737.0 80488.0
2 China 212258.0 213766.0

연도(year)라는 단일 변수에 대해 두 개의 열이 있는 것을 볼 수 있습니다. 이제 이를 녹여 보겠습니다.

코드 보기
df_tb.melt(
    id_vars=["country"],
    var_name="year",
    value_vars=["1999", "2000"],
    value_name="cases",
)
country year cases
0 Afghanistan 1999 745.0
1 Brazil 1999 37737.0
2 China 1999 212258.0
3 Afghanistan 2000 2666.0
4 Brazil 2000 80488.0
5 China 2000 213766.0

이제 행당 하나의 관측치, 열당 하나의 변수가 되었습니다. 깔끔해졌네요!

더 간단하게 넓은 형태에서 긴 형태로

melt()가 머리 아프다면, wide_to_long()도 있습니다. 다음과 같은 전형적인 데이터 정리 케이스에 매우 유용합니다:

코드 보기
import numpy as np

df = pd.DataFrame(
    {
        "A1970": {0: "a", 1: "b", 2: "c"},
        "A1980": {0: "d", 1: "e", 2: "f"},
        "B1970": {0: 2.5, 1: 1.2, 2: 0.7},
        "B1980": {0: 3.2, 1: 1.3, 2: 0.1},
        "X": dict(zip(range(3), np.random.randn(3))),
        "id": dict(zip(range(3), range(3))),
    }
)
df
A1970 A1980 B1970 B1980 X id
0 a d 2.5 3.2 0.771344 0
1 b e 1.2 1.3 0.838011 1
2 c f 0.7 0.1 -1.413594 2

즉, 열에 걸쳐 서로 다른 변수와 시기가 있는 데이터입니다. Wide to long을 사용하면 접두어(stubnames, 여기서는 ‘A’, ‘B’), 항상 열에 걸쳐 나타나는 변수의 이름(여기서는 연도), 임의의 값(여기서는 X), 그리고 id 열에 대한 정보를 제공할 수 있습니다.

코드 보기
pd.wide_to_long(df, stubnames=["A", "B"], i="id", j="year")
X A B
id year
0 1970 0.771344 a 2.5
1 1970 0.838011 b 1.2
2 1970 -1.413594 c 0.7
0 1980 0.771344 d 3.2
1 1980 0.838011 e 1.3
2 1980 -1.413594 f 0.1

쌓기(Stack)와 풀기(Unstack)

쌓기(stack())는 한 종류의 넓은 데이터 변수를 열에서 가져와 긴 형태의 데이터셋으로 바꾸는 지름길이지만, 인덱스가 추가됩니다.

풀기(unstack())는 당연히 동일한 작업을 반대로 수행합니다.

이를 시연하기 위해 멀티 인덱스 데이터 프레임을 정의해 보겠습니다:

코드 보기
tuples = list(
    zip(
        *[
            ["bar", "bar", "baz", "baz", "foo", "foo", "qux", "qux"],
            ["one", "two", "one", "two", "one", "two", "one", "two"],
        ]
    )
)
index = pd.MultiIndex.from_tuples(tuples, names=["first", "second"])
df = pd.DataFrame(np.random.randn(8, 2), index=index, columns=["A", "B"])
df
A B
first second
bar one 0.042293 0.624288
two 0.366402 -0.480216
baz one -0.715803 1.310166
two -0.389206 0.425451
foo one -1.238631 -1.554476
two 0.638349 1.528674
qux one -1.143951 -0.347938
two 0.187531 -2.300269

깔끔한 데이터셋을 만들기 위해 이것을 쌓아보겠습니다:

코드 보기
df = df.stack()
df
first  second   
bar    one     A    0.042293
               B    0.624288
       two     A    0.366402
               B   -0.480216
baz    one     A   -0.715803
               B    1.310166
       two     A   -0.389206
               B    0.425451
foo    one     A   -1.238631
               B   -1.554476
       two     A    0.638349
               B    1.528674
qux    one     A   -1.143951
               B   -0.347938
       two     A    0.187531
               B   -2.300269
dtype: float64

이것은 자동으로 다층 인덱스를 생성했지만, df.reset_index()를 사용하여 번호가 매겨진 인덱스로 되돌릴 수 있습니다.

이제 풀기(unstack)를 살펴보겠습니다. 처음 시작했던 ‘A’, ‘B’ 변수를 푸는 대신, level=0을 전달하여 ‘first’ 열을 풀어보겠습니다(기본값은 가장 안쪽 인덱스를 푸는 것입니다). 이 다이어그램은 무슨 일이 일어나고 있는지 보여줍니다:

코드는 다음과 같습니다:

코드 보기
df.unstack(level=0)
first bar baz foo qux
second
one A 0.042293 -0.715803 -1.238631 -1.143951
B 0.624288 1.310166 -1.554476 -0.347938
two A 0.366402 -0.389206 0.638349 0.187531
B -0.480216 0.425451 1.528674 -2.300269

{.callout-note} 연습 문제 대신 `level=1`로 풀면 어떻게 될까요? `unstack()`을 두 번 적용하면 어떻게 될까요?

긴 형태에서 넓은 형태로 데이터 피벗하기 (Pivoting)

pivot()pivot_table()은 단일 관측치가 여러 행에 걸쳐 흩어져 있는 데이터를 정리하는 데 도움을 줍니다.

관측치가 여러 행에 걸쳐 있는 예제 데이터 프레임입니다:

코드 보기
df_tb_cp = pd.read_parquet("data/who_tb_case_and_pop.parquet")
df_tb_cp.head()
country year type count
0 Afghanistan 1999-01-01 cases 745
1 Afghanistan 1999-01-01 population 19987071
2 Afghanistan 2000-01-01 cases 2666
3 Afghanistan 2000-01-01 population 20595360
4 Brazil 1999-01-01 cases 37737

각 연도-국가별로 서로 다른 행에 “case(사례)”와 “population(인구)”이 있는 것을 볼 수 있습니다.

이제 차이점을 확인하기 위해 이를 피벗해 보겠습니다:

코드 보기
pivoted = df_tb_cp.pivot(
    index=["country", "year"], columns=["type"], values="count"
).reset_index()
pivoted
type country year cases population
0 Afghanistan 1999-01-01 745 19987071
1 Afghanistan 2000-01-01 2666 20595360
2 Brazil 1999-01-01 37737 172006362
3 Brazil 2000-01-01 80488 174504898
4 China 1999-01-01 212258 1272915272
5 China 2000-01-01 213766 1280428583

피벗은 시계열 데이터에서 특히 유용합니다. 시계열 데이터에서 shift()diff()와 같은 연산은 일반적으로 한 행의 항목이 위 행의 항목을 (시간상으로) 뒤따른다고 가정하고 적용됩니다. shift()를 수행할 때 종종 단일 변수를 시간상으로 이동시키고 싶어 하지만, 단일 관측치(이 경우 날짜)가 여러 행에 걸쳐 있다면 타이밍이 어긋나게 됩니다. 예제를 살펴보겠습니다.

코드 보기
import numpy as np

data = {
    "value": np.random.randn(20),
    "variable": ["A"] * 10 + ["B"] * 10,
    "date": (
        list(pd.date_range("1/1/2000", periods=10, freq="ME"))
        + list(pd.date_range("1/1/2000", periods=10, freq="ME"))
    ),
}
df = pd.DataFrame(data, columns=["date", "variable", "value"])
df.sample(5)
date variable value
4 2000-05-31 A -2.074953
9 2000-10-31 A -0.156022
11 2000-02-29 B 0.070457
5 2000-06-30 A 1.814262
19 2000-10-31 B -0.894467

위의 데이터에 그냥 shift()를 실행하면, 변수 B와 A가 시간상으로 겹치고 서로 다른 변수임에도 불구하고 함께 이동하게 됩니다. 따라서 더 넓은 포맷으로 피벗합니다(그러면 시간상으로 안전하게 이동시킬 수 있습니다).

코드 보기
df.pivot(index="date", columns="variable", values="value").shift(1)
variable A B
date
2000-01-31 NaN NaN
2000-02-29 -0.752495 2.361353
2000-03-31 0.828459 0.070457
2000-04-30 -0.949257 -1.625747
2000-05-31 0.360377 -0.584994
2000-06-30 -2.074953 -0.186479
2000-07-31 1.814262 0.692381
2000-08-31 0.044257 0.289220
2000-09-30 -0.434608 -0.124198
2000-10-31 1.408759 1.800461

왜 첫 번째 항목이 NaN일까요?

위의 예제에서 카테고리가 df["category"] = np.random.choice(["type1", "type2", "type3", "type4"], 20)로 정의되어 있을 때, variablecategory 열 모두에 적용되는 pivot()을 수행해 보세요. (힌트: 여러 객체를 리스트를 통해 전달해야 함을 기억하세요.)