깔끔한 데이터(Tidy data)

깔끔한 데이터(Tidy data)란?

우리는 데이터 분석을 수행하면서 다양한 데이터 변환 작업을 수행하게 된다. 이는 데이터가 원래 특정 분석을 염두에 두고 만들어지는 경우가 거의 없기 때문이며, 사실 애초 데이터 설계를 할 때 분석 목적을 알기도 불가능하다는 게 가장 큰 원인이 아닐까 한다. 이런 연유로 전체 데이터 분석 작업에서 70% 혹은 80% 이상이 이런 데이터 변환 및 전처리 작업에서 소모된다. 1

다른 사람들과 데이터(예를 들면 엑셀파일)를 주고 받다 보면 깨닫게 됩니다. 모든 사람의 생각은 다르다는 것을요. 사람마다 (좋게 말하면) 각양각색으로 정리를 하다보니, 여러명이 작성한 엑셀 파일을 하나의 파일로 합치려 보면 결국에는 누군가가 새로 정리를 해야 하는 상황이 됩니다.

Tidy data의 개념은 그런 상황을 방지하기 위해 Jeff Leek이 만들었습니다. 데이터 처리에 가장 많은 시간이 소요되는 전처리 과정을 줄여 보자, 그러기 위해 처음부터 데이터를 표준화해서 만들자는 것입니다. 처음부터 기본적인 규칙을 가지고 데이터를 만들면, 모두의 시간을 아낄 수 있으니까요.

위키피디아에 따르면 Tidy data의 정의는 다음와 같습니다. "밑바닥 부터 시작할 필요 없는 데이터"

Tidy data there’s no need to start from scratch. -from wiki

더 알아 보기

  • 2014년도에 출판된 논문 입니다.
  • 데이터의 출처는 이곳 입니다.

깔끔한 데이터(Tidy data)의 특징

Jeff Leek가 쓴 책 The Elements of Data Analytic Style에서 정의한 깔끔한 데이터는 아래와 같은 특징을 가집니다.

  1. 각 변수는 개별의 열(column)으로 존재한다.
  2. 각 관측치는 행(row)를 구성한다.
  3. 각 표는 단 하나의 관측기준에 의해서 조직된 데이터를 저장한다.
  4. 만약 여러개의 표가 존재한다면, 적어도 하나이상의 열(column)이 공유되어야 한다.
  • 변수(Variable): 예를 들면 키, 몸무게, 성별
  • 값(Value): 예를 들자면 152 cm, 80 kg, 여성
  • 관측치(Observation): 값을 측정한 단위, 여기서는 각각의 사람

너무 복잡하다고 생각되신다면 아래 예시를 확인하세요.

지저분한 데이터의 예:

Treatment A Treatment B
John Smith - 2
Jane Doe 16 11
Mary Johnson 3 1

깔끔한 데이터(Tidy data)의 예:

Name Treatment Result
John Smith a -
Jane Doe a 16
Mary Johnson a 3
John Smith b 2
Jane Doe b 11
Mary Johnson b 1

지저분한 데이터 처리하기

Wickham논문에 있던 데이터를 가지고 아래의 문제를 해결해 보도록 하겠습니다. 여기서 우리의 목표는 데이터 분석이 아니고 깔끔한 데이터를 만드는 것임을 명심하세요.

지저분한 데이터의 일반적인 모습은 다음과 같습니다.

  1. 열 이름(Column header)이 변수 이름이 아니고 값인 경우
  2. 같은 표에 다양한 관측 단위(observational units)가 있는 경우
  3. 하나의 열(column)에 여러 값이 들어 있는 경우
  4. 변수가 행과 열에 모두 포함되어 있는 경우
  5. 하나의 관측 단위(observational units)가 여러 파일로 나누어져 있는 경우

우리는 Python의 대표적인 라이브러리인 Pandas를 사용하도록 할게요.

In [6]:
# 필요한 라이브러리 불러들이기
import pandas as pd
import datetime
import glob
import re  # 정규식

1. 열 이름(Column header)이 변수 이름이 아니고 값인 경우

Pew Research Center Dataset

종교에 따른 개인의 수입의 관한 데이터입니다. 먼저 pandasread_csv기능을 사용해 파일을 읽어옵니다.

In [7]:
df = pd.read_csv("./data/pew-raw.csv")
df
Out[7]:
religion <$10k $10-20k $20-30k $30-40k $40-50k $50-75k
0 Agnostic 27 34 60 81 76 137
1 Atheist 12 27 37 52 35 70
2 Buddhist 27 21 30 34 33 58
3 Catholic 418 617 732 670 638 1116
4 Dont know/refused 15 14 15 11 10 35
5 Evangelical Prot 575 869 1064 982 881 1486
6 Hindu 1 9 7 9 11 34
7 Historically Black Prot 228 244 236 238 197 223
8 Jehovahs Witness 20 27 24 24 21 30
9 Jewish 19 19 25 25 30 95
  • Evangelical Prot는 기독교 종파중에 하나로 '개신교'입니다.

문제점: 이 데이터들의 문제는 열 이름(columns headers)이 개인소득의 범위로 되어 있다는 것이죠.

다시 말해서 보기에는 좋아보일지는 몰라도 분석하기에는 어려운 형식입니다.

이러한 데이터를 Tidy data 형태로 변환하기 위해서 pandas라이브러리에서는 아주 쉬운 기능을 제공해 줍니다. 바로 melt라는 기능이지요. pivot table의 반대되는 개념으로 행이 많은 데이터를 열이 많은 데이터로 바꿔줍니다. melt는 아주 유용하기 때문에 앞으로도 자주 언급 됩니다.

In [8]:
formatted_df = pd.melt(df, ["religion"], var_name="income", value_name="freq")
formatted_df = formatted_df.sort_values(by=["religion"])  # 종교 이름순으로 정렬
formatted_df.head(10)  # 너무 길기 때문에 윗쪽 10개만 보겠습니다.
Out[8]:
religion income freq
0 Agnostic <$10k 27
30 Agnostic $30-40k 81
40 Agnostic $40-50k 76
50 Agnostic $50-75k 137
10 Agnostic $10-20k 34
20 Agnostic $20-30k 60
41 Atheist $40-50k 35
21 Atheist $20-30k 37
11 Atheist $10-20k 27
31 Atheist $30-40k 52

이것이 Pew Research Center Dataset 의 Tidy data 형태 입니다.

Billboard Top 100 Dataset

이 데이터는 아주 오래전 같은 1999년부터 2000년까지의 빌보드차트 주간 순위 변동을 포함하고 있는 데이터 입니다.

In [69]:
df = pd.read_csv("./data/billboard.csv", encoding="mac_latin2")
df.head(10)
Out[69]:
year artist.inverted track time genre date.entered date.peaked x1st.week x2nd.week x3rd.week ... x67th.week x68th.week x69th.week x70th.week x71st.week x72nd.week x73rd.week x74th.week x75th.week x76th.week
0 2000 Destiny's Child Independent Women Part I 3:38 Rock 2000-09-23 2000-11-18 78 63.0 49.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
1 2000 Santana Maria, Maria 4:18 Rock 2000-02-12 2000-04-08 15 8.0 6.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
2 2000 Savage Garden I Knew I Loved You 4:07 Rock 1999-10-23 2000-01-29 71 48.0 43.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
3 2000 Madonna Music 3:45 Rock 2000-08-12 2000-09-16 41 23.0 18.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
4 2000 Aguilera, Christina Come On Over Baby (All I Want Is You) 3:38 Rock 2000-08-05 2000-10-14 57 47.0 45.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
5 2000 Janet Doesn't Really Matter 4:17 Rock 2000-06-17 2000-08-26 59 52.0 43.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
6 2000 Destiny's Child Say My Name 4:31 Rock 1999-12-25 2000-03-18 83 83.0 44.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
7 2000 Iglesias, Enrique Be With You 3:36 Latin 2000-04-01 2000-06-24 63 45.0 34.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
8 2000 Sisqo Incomplete 3:52 Rock 2000-06-24 2000-08-12 77 66.0 61.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN
9 2000 Lonestar Amazed 4:25 Country 1999-06-05 2000-03-04 81 54.0 44.0 ... NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN

10 rows × 83 columns

문제점:

  • 열 이름(columns headers)이 값으로 구성되어 있다: x1st.week, x2st.week 등등
  • 순위가 100위권 밖으로 밀려나게 되면 Nan 값을 가지고, 따라서 필요없는 부수적인 값이 많아진다.

위 데이터의 깔끔한 데이터를 만들기 위해서는 다시 melt 기능을 사용하겠습니다. 각각의 열이 행이 되도록 하고, 순위가 100위 밖으로 밀려난 경우(Nan값을 갖는경우)에는 행을 삭제할게요.

In [70]:
# Melting
id_vars = [
    "year",
    "artist.inverted",
    "track",
    "time",
    "genre",
    "date.entered",
    "date.peaked",
]
df = pd.melt(frame=df, id_vars=id_vars, var_name="week", value_name="rank")

# Formatting
df["week"] = (
    df["week"].str.extract("(\d+)", expand=False).astype(int)
)  # 정규식으로 x1st.week 에서 숫자 1만 추출
df["rank"] = df["rank"].astype(int)

# 필요없는 행을 삭제합니다.
df = df.dropna()

# Create "date" columns
df["date"] = (
    pd.to_datetime(df["date.entered"])
    + pd.to_timedelta(df["week"], unit="w")
    - pd.DateOffset(weeks=1)
)

df = df[["year", "artist.inverted", "track", "time", "genre", "week", "rank", "date"]]
df = df.sort_values(
    ascending=True, by=["year", "artist.inverted", "track", "week", "rank"]
)

# Assigning the tidy dataset to a variable for future usage
billboard = df

df.head(10)
Out[70]:
year artist.inverted track time genre week rank date
246 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 1 87 2000-02-26
563 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 2 82 2000-03-04
880 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 3 72 2000-03-11
1197 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 4 77 2000-03-18
1514 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 5 87 2000-03-25
1831 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 6 94 2000-04-01
2148 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 7 99 2000-04-08
287 2000 2Ge+her The Hardest Part Of Breaking Up (Is Getting Ba... 3:15 R&B 1 91 2000-09-02
604 2000 2Ge+her The Hardest Part Of Breaking Up (Is Getting Ba... 3:15 R&B 2 87 2000-09-09
921 2000 2Ge+her The Hardest Part Of Breaking Up (Is Getting Ba... 3:15 R&B 3 92 2000-09-16

위와 같이 데이터를 깔끔하게 만들었습니다. 하지만 track, time, genre 열을 보시면 아주 많은 중복이 있는 것을 알 수 있습니다. 이러한 점을 해결 하는 방법은 다음 예제에서 다루어 보겠습니다.

2. 하나의 표에 여러가지 타입

위에서 다루었던 빌보드차트 데이터를 가지고 데이터가 반복되는 문제를 해결해 보겠습니다.

묹제점:

  • 다양한 관측 단위(observational units), 여기서는 songrank가 하나의 표에 들어 있습니다. 이를 위해서는 표를 나눌 필요가 있습니다.

먼저, 각각의 노래의 자세한 내용을 담고 있는 표를 만들어 보겠습니다. 그런다음 각각의 song_id를 부여합니다. 그런다음 순위 값을 가지고 있는 표를 song_id로 정리합니다.

In [11]:
songs_cols = ["year", "artist.inverted", "track", "time", "genre"]
songs = billboard[songs_cols].drop_duplicates()
songs = songs.reset_index(drop=True)
songs["song_id"] = songs.index
songs.head(10)
Out[11]:
year artist.inverted track time genre song_id
0 2000 2 Pac Baby Don't Cry (Keep Ya Head Up II) 4:22 Rap 0
1 2000 2Ge+her The Hardest Part Of Breaking Up (Is Getting Ba... 3:15 R&B 1
2 2000 3 Doors Down Kryptonite 3:53 Rock 2
3 2000 3 Doors Down Loser 4:24 Rock 3
4 2000 504 Boyz Wobble Wobble 3:35 Rap 4
5 2000 98° Give Me Just One Night (Una Noche) 3:24 Rock 5
6 2000 A*Teens Dancing Queen 3:44 Pop 6
7 2000 Aaliyah I Don't Wanna 4:15 Rock 7
8 2000 Aaliyah Try Again 4:03 Rock 8
9 2000 Adams, Yolanda Open My Heart 5:30 Gospel 9

위 와같은 새로운 표를 분리하고, 아래와 같이 순위를 포함하고 있는 표를 새로 만들어줍니다.

두개의 표를 연결하기 위해 song_id열을 만드는 것을 주의하세요

In [12]:
ranks = pd.merge(
    billboard, songs, on=["year", "artist.inverted", "track", "time", "genre"]
)
ranks = ranks[["song_id", "date", "rank"]]
ranks.head(10)
Out[12]:
song_id date rank
0 0 2000-02-26 87
1 0 2000-03-04 82
2 0 2000-03-11 72
3 0 2000-03-18 77
4 0 2000-03-25 87
5 0 2000-04-01 94
6 0 2000-04-08 99
7 1 2000-09-02 91
8 1 2000-09-09 87
9 1 2000-09-16 92

3. 다양한 변수가 하나의 열에 있는 경우 Multiple variables stored in one column

Tubercolosis Example

WHO(World Health Organization)에서 수집한 결핵환자의 기록입니다. 이 데이터에는 확인된 결핵환자의 국가, 연도, 나이, 성별이 포함되어 있습니다.

문제점:

  • 몇개의 열(columns)에 다양한 변수가 포함되어 있습니다.(성별과 나이)
  • 값이 존재하지 않는 곳에 NaN과 0 이 혼재되어 있습니다.

미리 알아둘 점:

  • 열의 이름에 적혀있는 "m"이나 "f"는 성별을 뜻합니다.
  • 열의 이름에 적혀있는 숫자는 나이대("0-14","15-24", "25-34", "45-54", "55-64", "65", "unknown")를 나타냅니다.
In [49]:
df = pd.read_csv("./data/tb-raw.csv")
df
Out[49]:
country year m014 m1524 m2534 m3544 m4554 m5564 m65 mu f014
0 AD 2000 0.0 0.0 1.0 0.0 0 0 0.0 NaN NaN
1 AE 2000 2.0 4.0 4.0 6.0 5 12 10.0 NaN 3.0
2 AF 2000 52.0 228.0 183.0 149.0 129 94 80.0 NaN 93.0
3 AG 2000 0.0 0.0 0.0 0.0 0 0 1.0 NaN 1.0
4 AL 2000 2.0 19.0 21.0 14.0 24 19 16.0 NaN 3.0
5 AM 2000 2.0 152.0 130.0 131.0 63 26 21.0 NaN 1.0
6 AN 2000 0.0 0.0 1.0 2.0 0 0 0.0 NaN 0.0
7 AO 2000 186.0 999.0 1003.0 912.0 482 312 194.0 NaN 247.0
8 AR 2000 97.0 278.0 594.0 402.0 419 368 330.0 NaN 121.0
9 AS 2000 NaN NaN NaN NaN 1 1 NaN NaN NaN

이 데이터를 정리하기 위해서는 먼저 melt를 이용해 sex + age group 를 합쳐서 하나의 행으로 만들겠습니다. 그런 다음에 다시 행을 sex, age로 구분해서 정리하도록 하죠.

In [50]:
df = pd.melt(
    df, id_vars=["country", "year"], value_name="cases", var_name="sex_and_age"
)

# Extract Sex, Age lower bound and Age upper bound group
tmp_df = df["sex_and_age"].str.extract("(\D)(\d+)(\d{2})", expand=False)

# Name columns
tmp_df.columns = ["sex", "age_lower", "age_upper"]

# Create `age`column based on `age_lower` and `age_upper`
tmp_df["age"] = tmp_df["age_lower"] + "-" + tmp_df["age_upper"]

# Merge
df = pd.concat([df, tmp_df], axis=1)

# Drop unnecessary columns and rows
df = df.drop(["sex_and_age", "age_lower", "age_upper"], axis=1)
df = df.dropna()
df = df.sort_values(ascending=True, by=["country", "year", "sex", "age"])
df.head(10)
Out[50]:
country year cases sex age
0 AD 2000 0.0 m 0-14
10 AD 2000 0.0 m 15-24
20 AD 2000 1.0 m 25-34
30 AD 2000 0.0 m 35-44
40 AD 2000 0.0 m 45-54
50 AD 2000 0.0 m 55-64
81 AE 2000 3.0 f 0-14
1 AE 2000 2.0 m 0-14
11 AE 2000 4.0 m 15-24
21 AE 2000 4.0 m 25-34

이것이 정리된 결과 입니다.

4. 변수가 행과 열에(rows and columns) 모두 포함되어 있는 경우

Global Historical Climatology Network Dataset

이것은 2010년도 멕시코 기상청(MX17004)에서 5개월 동안 측정한 기상 데이터입니다.

In [24]:
df = pd.read_csv("./data/weather-raw.csv")

문제점:

  • 변수들이 행(tmin, tmax) 과 열(days)에 존재합니다.

깔끔한 데이터를 만들기 위해 melt기능을 사용해 day_raw를 각각의 열로 만들겠습니다.

In [25]:
df = pd.melt(df, id_vars=["id", "year", "month", "element"], var_name="day_raw")
df.head(10)
Out[25]:
id year month element day_raw value
0 MX17004 2010 1 tmax d1 NaN
1 MX17004 2010 1 tmin d1 NaN
2 MX17004 2010 2 tmax d1 NaN
3 MX17004 2010 2 tmin d1 NaN
4 MX17004 2010 3 tmax d1 NaN
5 MX17004 2010 3 tmin d1 NaN
6 MX17004 2010 4 tmax d1 NaN
7 MX17004 2010 4 tmin d1 NaN
8 MX17004 2010 5 tmax d1 NaN
9 MX17004 2010 5 tmin d1 NaN

그럼에도 아직 불필요한 것들이 보이는 군요. 좀 더 깔끔한 데이터를 만들기 위해 tmin, tmax를 각각의 열로 만들겠습니다. 그리고 날짜 정보들을 합쳐서 date로 통합하겠습니다.

In [26]:
# Extracting day
df["day"] = df["day_raw"].str.extract("d(\d+)", expand=False)
df["id"] = "MX17004"

# To numeric values
df[["year", "month", "day"]] = df[["year", "month", "day"]].apply(
    lambda x: pd.to_numeric(x, errors="ignore")
)


# Creating a date from the different columns
def create_date_from_year_month_day(row):
    return datetime.datetime(year=row["year"], month=int(row["month"]), day=row["day"])


df["date"] = df.apply(lambda row: create_date_from_year_month_day(row), axis=1)
df = df.drop(["year", "month", "day", "day_raw"], axis=1)
df = df.dropna()

# Unmelting column "element"
df = df.pivot_table(index=["id", "date"], columns="element", values="value")
df.reset_index(drop=False, inplace=True)
df
Out[26]:
element id date tmax tmin
0 MX17004 2010-02-02 27.3 14.4
1 MX17004 2010-02-03 24.1 14.4
2 MX17004 2010-03-05 32.1 14.2

충분히 깔끔한 모양새가 되었습니다.

5. 하나의 관측 단위(observational units)가 여러 파일로 나누어져 있는 경우

Baby Names in Illinois

2014, 2015년도 미국 일리노이 주(Illinois)의 신생아의 (남자)이름을 수집한 데이터 입니다.

문제점:

  • 여러 표와 파일에 데이터가 흩어져 있다.
  • 연도(Year)”가 파일 이름에 적혀져 있다.

서로 다른 파일에 데이터가 흩어져 있어 조금 복잡한 과정이 필요합니다. 먼저 아래의 코드로 파일 리스트를 만들고 거기에서 연도 값을 뽑아냅니다. 그런 다음 각각의 파일에서 표를 만들어내고 마지막으로 concat기능으로 사용해 하나의 표로 합치겠습니다.

In [5]:
def extract_year(string):
    match = re.match(".+(\d{4})", string)
    if match is not None:
        return match.group(1)


path = "./data"
allFiles = glob.glob(path + "/201*-baby-names-illinois.csv")
frame = pd.DataFrame()
df_list = []
for file_ in allFiles:
    df = pd.read_csv(file_, index_col=None, header=0)
    df.columns = map(str.lower, df.columns)
    df["year"] = extract_year(file_)
    df_list.append(df)

df = pd.concat(df_list)
df.head(5)
Out[5]:
rank name frequency sex year
0 1 Noah 837 Male 2014
1 2 Alexander 747 Male 2014
2 3 William 687 Male 2014
3 4 Michael 680 Male 2014
4 5 Liam 670 Male 2014

마치며

이 글에서 가장 중점으로 둔것은 파이썬으로 지저분한 데이터를 깔끔하게 만드는 것이 었습니다. 그걸을 위해 Wickham의 논문에서 사용된 데이터를 살펴 보았죠. 깔끔한 데이터(Tidy data)의 최고의 장점은 시각화(Visualization)이 쉽다는 것에 있습니다. 그것은 다음에 다루어 보도록 하겠습니다.

앞으로는 Tidy data를 고려해서 데이터를 수집하도록 하세요. 모두의 시간은 소중하니까요.