
0. 시작하면서
데이터 분석 접해봤다면 "전처리는 데이터분석의 전 과정 중 80%에 해당한다."는 이야기를 들어봤을 겁니다. 그만큼 데이터를 분석하기에 앞서 분석가가 사용할 데이터를 얼마나 잘 가공하느냐에 따라 분석의 성공여부가 갈릴 수 있다는 말이기도 합니다. 그리고 케글이나 데이터 공모전에 나오는 데이터는 잘 가공된 데이터를 사용하지만, 실제로 현업에 가면 날 것의 데이터를 내가 어떻게, 어느 정도로 가공하느냐에 따라 사용 가능한 범위도 달라지고, 분석의 깊이도 달라집니다.
그만큼 분석에 있어 전처리에 대한 방법이 중요하기 때문에, 이번 장과 다음 장까지는 기본적인 전처리 방법을 다룰 예정이며, 이후에도 추가적인 방법들을 더 다뤄볼 예정입니다. 이번 장에서는 기본적인 전처리 중 NULL 값에 대한 처리와 범주형 값들에 대한 처리를 알아보도록 하겠습니다.
1. 결측치의 의미
데이터를 수집하고 탐색하다보면 가장 눈에 띄는 것은 해당 변수(컬럼)에 누락된 데이터가 가장 먼저 보입니다. 실제 애플리케이션에서는 여러 이유로 하나 이상의 값이 누락된 샘플인 경우가 매우 드물지만, 수집 과정에서 오류가 있었거나, 측정이 불가능한 경우일 수 있습니다.
이렇게 누락된 데이터를 표현하는 방식은 의미에 따라 2가지로 나눌 수 있는데, 실제 값이 아닌 경우(NA, NaN) 이거나, 값이 미정인 경우(NULL)가 있습니다. 참고로 R에서는 NaN (Not a Number) 와 NA(Not Available)을 동일하게 보지만, 파이썬에서는 이 둘도 서로 다른 경우로 본다는 점을 참고 바랍니다.
1.1 누락된 값 확인하기
우선 시작에 앞서 이해를 돕기 위해 아래와 같이 샘플 데이터가 있다고 가정해보겠습니다.
[샘플 데이터]
a b c d
1 2 3.0 4
5 6 NaN 8
9 10 11.0 12
위의 샘플 데이터는 데이터 수 자체가 많지 않아 확인이 쉽지만, 실무에서는 몇 백만 개가 되는 데이터 상에서 결측치를 찾아야 됩니다. 이를 위해 파이썬의 경우에는 데이터프레임 객체가 갖고 있는 isna(), isnull() 메소드를, R의 경우에는 is.na(), is.null() 함수를 사용해서 찾아내면 됩니다.
[Python Code]
import numpy as np
import pandas as pd
# 결측치 탐색
na_indices = np.where(pd.isnull(df))
print("결측치의 인덱스:")
for index in range(len(na_indices[0])):
print(f"행: {na_indices[0][index]}, 열: {na_indices[1][index]}")
[R Code]
# 결측치 탐색
na_indices <- which(is.na(df), arr.ind = TRUE)
print(na_indices)
[실행결과]
row col
[1,] 2 3
1.2 누락된 값의 제외
그렇다면 누락된 값에 대해서는 어떻게 처리를 하는 게 맞을까요? 누락된 값은 경우에 따라 크게 제외하거나, 특정 값으로 대체하는 방식이 대표적입니다.
먼저 살펴볼 것은 제외에 대한 방법을 보겠습니다. 데이터 셋에서 해당 샘플(데이터) 나 특성(컬럼)을 완전 삭제하는 방법입니다. 누락된 값을 삭제할 때는 파이썬에서는 dropna() 메소드를, R에서는 사용합니다. 이때, axis 매개 변수를 이용해서 행 또는 열을 삭제할지 설정할 수 있으며, 기본 값은 0이고, 행을 기준으로 삭제합니다. 만약 컬럼을 삭제하고 싶다면, axis = 1로 설정하면 됩니다.
[Python Code]
# 결측치가 있는 행 제거
cleaned_df_rows = df.dropna() # axis 기본 값 = 0
# 결측치가 있는 열 제거
cleaned_df_cols = df.dropna(axis=1)
[실행결과]
결측치가 제거된 데이터프레임 (행 제거):
A B C D
0 1 2 3.0 4
2 9 10 11.0 12
결측치가 제거된 데이터프레임 (열 제거):
A B D
0 1 2 4
1 5 6 8
2 9 10 12
[R Code]
# NA 값을 포함한 행을 제외한 데이터프레임 생성
cleaned_df <- df[complete.cases(df), ]
# NA 값을 포함한 열을 제외한 데이터프레임 생성
cleaned_df_col <- df[, colSums(is.na(df)) == 0]
[실행결과]
A B C D
1 1 2 3 4
3 9 10 11 12
A B D
1 1 2 4
2 5 6 8
3 9 10 12
만약 하나의 행 또는 열이 모두 NaN인 경우에는 아래와 같은 방법으로 한 번에 제거해 주는 것이 좋습니다.
[Python Code]
df2
df2.dropna(how='all')
df2.dropna(axis=1, how='all')
[실행 결과]
a b c d
0 1.0 2.0 3.0 NaN
1 5.0 6.0 NaN NaN
2 NaN NaN NaN NaN
a b c d
0 1.0 2.0 3.0 NaN
1 5.0 6.0 NaN NaN
a b c
0 1.0 2.0 3.0
1 5.0 6.0 NaN
2 NaN NaN NaN
[R Code]
# 모든 결측치를 가진 행 제거
df2_1 <- df2[!apply(is.na(df2), 1, all), ]
print(df2_1)
# 모든 결측치를 가진 열 제거
df2_2 <- df2[, colSums(is.na(df2)) != nrow(df2)]
print(df2_2)
[실행결과]
a b c d
1 1 2 3 NaN
2 5 6 NaN NaN
a b c
1 1 2 3
2 5 6 NaN
3 NaN NaN NaN
위의 내용들만 살펴보면 누락된 데이터를 제거하는 방법은 쉽게 보일 수 있지만, 반대로 너무 많은 데이터를 제거하게 되면 안정적인 분석이 어려워질 수 있다는 단점이 있습니다. 뿐만 아니라 너무 많은 특성 열을 제거하면 분류기가 클래스를 구분하는 데 필요한 중요한 정보를 잃을 수 있다는 위험도 존재합니다. 이러한 단점을 해결할 방법 중 하나가 대체 즉, 보간 기법입니다.
1.3 누락된 값의 대체
앞서 살펴본 데로 누락된 값에 대해 삭제를 하는 방법이 있지만, 특정 샘플만 삭제하거나, 특정 열을 통째로 삭제하기가 어려운 경우가 있습니다. 누락된 값 때문에 주요한 혹은 유용한 데이터까지 삭제하는 것부터가 손해인 경우가 많기 때문입니다.
이럴 경우 누락된 값에 대해 특정 값으로 대체하는, 보간 기법을 이용하면 큰 손실 없이 누락된 값에 대한 처리가 가능합니다. 가장 흔하게 사용되는 보간 기법으로는 누락된 값을 평균값으로 대체하는 방법이며, 각 특성 열의 전체 평균을 누락된 값이 존재하는 경우 대체하는 방법입니다. 구현하는 방법은 다음과 같습니다.
[Python Code]
# 평균값으로 대체
df.fillna(df.mean(), inplace=True)
print(df)
[실행결과]
A B C D
0 1 2 3.0 4
1 5 6 7.0 8
2 9 10 11.0 12
[R Code]
# 평균값으로 대체
df3 <- apply(df, 2, function(x) ifelse(is.na(x), mean(x, na.rm = TRUE), x))
print(df3)
[실행결과]
A B C D
[1,] 1 2 3 4
[2,] 5 6 7 8
[3,] 9 10 11 12
파이썬의 경우에는 scikit-learn 라이브러리에 위의 방식을 Imputer 클래스로 구현했는데, 해당 방식으로 구현할 시, 사용법은 아래와 같습니다.
[Python Code]
from sklearn.impute import SimpleImputer
print(df)
imr = SimpleImputer(strategy='mean')
imr = imr.fit(df.values)
imputed_data = imr.transform(df.values)
imputed_data
[실행 결과]
a b c d
0 1 2 3.0 4
1 5 6 NaN 8
2 9 10 11.0 12
array([[ 1., 2., 3., 4.],
[ 5., 6., 7., 8.],
[ 9., 10., 11., 12.]])
위의 실행 결과를 보면 알 수 있듯이 6과 8 사이에 평균값으로 7이 채워진 것을 확인할 수 있습니다. 이렇듯이 결측값을 Strategy 매개 변수에 정의한 방법으로 계산하여 대체하며, 연산은 mean(평균), median(중앙값), most_frequent(최빈값)가 있습니다.
2. 범주형 데이터 다루기
이번에는 범주형 데이터에 대해서 알아보도록 하자. 데이터를 살펴보면, 수치형의 값을 갖는 변수뿐만 아니라, 등급, 순위와 같은 범주형(Categorical) 특성을 갖는 데이터 역시 볼 기회가 있습니다. 범주형 데이터의 경우 크게 순서가 있는 특성과 순서와 상관없는 특성으로 구분할 수 있습니다.
2.1 순서가 있는 특성
예를 들어 아래와 같은 데이터 셋이 있다고 가정해 보겠습니다.
[예시]
no color shape number
0 1 B S 13
1 2 R D 9
2 3 R H 9
3 4 B C 3
4 5 B S 12
이때, shape의 순서가 S > D > C > H 순서로 되어야 한다고 가정했을 때, 범주형의 문자값을 숫자로 변환해야 순서를 정하는 것이 좀 더 편리할 겁니다. 하지만, 위의 내용대로 만들어 주는 함수가 없기에, 아래의 코드와 같이 직접 구현해 보겠습니다.
[Python Code]
shape_order = {
'S' : 1,
'D' : 2,
'C' : 3,
'H' : 4
}
data['shape'] = data['shape'].map(shape_order)
data
[실행 결과]
no color shape number
0 1 B 1 13
1 2 R 2 9
2 3 R 4 9
3 4 B 3 3
4 5 B 1 12
R의 경우에는 아래 코드에서처럼 벡터에 값에 대응하는 이름을 지정할 수 있기 때문에 파이썬보다는 조금 더 간편하게 치환하는 것이 가능합니다.
[R Code]
shape_order <- c(S = 1, D = 2, C = 3, H = 4)
data$shape <- shape_order[data$shape]
print(data)
[실행결과]
no color shape number
1 1 B 1 13
2 2 R 2 9
3 3 R 4 9
4 4 B 3 3
5 5 B 1 12
파이썬의 경우에는 위의 예제에서처럼 순서에 대한 데이터를 생성해 준 다음, map() 함수를 사용해 데이터 전체에 적용시킬 수 있습니다. 참고로, 나중에 정수 값을 다시 문자열로 매핑하고 싶다면 아래의 코드를 추가해 주면 됩니다.
[Python Code]
inv_shape_order = {v: k for k, v in shape_order.items()}
data['shape'].map(inv_shape_order)
[실행 결과]
0 S
1 D
2 H
3 C
4 S
Name: shape, dtype: object
추가적으로 클래스 레이블이 정수로 인코딩 되는 경우도 생각할 수 있습니다. 일반적으로 사이킷런의 분류 추정기 모델의 대다수는 자체적으로 클래스 레이블을 정수로 변환해 주지만, 사소한 실수의 발생을 막기 위해 위와 같이 정수로 변환해 별도의 리스트에 저장해 두는 것이 좋습니다.
앞서 언급한 것처럼, 클래스 레이블 역시 순서 특성을 매핑하는 것과 유사한 방식을 이용합니다. 단, 순서는 존재하지 않습니다. 가장 쉬운 방법은 enumerate()를 사용해서 클래스 레이블을 0부터 채워 나가는 방법입니다.
[Python Code]
import numpy as np
class_mapping = {label : idx for idx, label in enumerate(np.unique(data["shape"]))}
class_mapping
[실행 결과]
{'C': 0, 'D': 1, 'H': 2, 'S': 3}
다음으로 위의 내용으로 클래스 레이블을 변형합니다.
[Python Code]
data["shape"] = data["shape"].map(class_mapping)
data
[실행 결과]
no color shape number
0 1 B 3 13
1 2 R 1 9
2 3 R 2 9
3 4 B 0 3
4 5 B 3 12
정수 값을 다시 클래스 레이블로 변환하는 방법은 앞서 본 정수 값을 문자열로 바꿔주는 방법을 그대로 이용하면 됩니다. 이제 사이킷 런에서 클래스 레이블을 변환하는 방법을 살펴보겠습니다. 사이킷 런에서는 LabelEncoder 클래스를 사용하면 되고, 사용 방법은 다음과 같습니다.
[Python Code]
from sklearn.preprocessing import LabelEncoder
labeler = LabelEncoder()
y = labeler.fit_transform(data["shape"].values)
y
[실행 결과]
array([3, 1, 2, 0, 3])
위의 코드 중에 fit_transform() 메소드는 fit() 메소드와 transform() 메소드를 합친 단축 메소드입니다. 때문에 inverse_transform() 메소드를 사용해서 정수 레이블을 문자열로 변환할 수 있습니다.
[Python Code]
labeler.inverse_transform(y)
[실행 결과]
array(['S', 'D', 'H', 'C', 'S'], dtype=object)
2.2 순서가 없는 특성
지금까지는 딕셔너리 매핑 방식으로 순서를 가진 범주형 특성을 처리하는 방법을 사용했으며, 파이썬의 경우 사이킷런에서 LabelEncoder를 이용해서 간편하게 문자열을 정수형으로 변환하였습니다. 순서 없는 컬럼에도 비슷한 방식을 사용할 수 있다. 위의 데이터 중 color에 적용해 보겠습니다.
[Python Code]
y_color = labeler.fit_transform(data["color"].values)
y_color
[실행 결과]
array([0, 1, 1, 0, 0])
물론 현재는 레이블이 2개이지만, 여러 개일 경우 정수형으로 변환 후에 바로 분류 모델에 주입하게 되면, 모델은 숫자가 큰 순서대로 순서가 존재한다고 가정하게 됩니다. 물론 잘못된 오류이지만, 의미 있는 결과가 도출될 수도 있습니다. 하지만, 좋은 방법은 아니기 때문에 모델에 주입하기 전 순서를 없앨 방법이 필요합니다. 이때 사용되는 기법 중 하나가 원-핫 인코딩(One Hot Encoding)입니다. 이는 순서가 없는 특성에 들어 있는 고유 값마다 새로운 더미(dummy) 변수를 만드는 기법입니다. 예를 들어, 앞선 예제에서 검정색(B)이면 B=1, R=0과 같은 식의 이진 값으로 표현하게 됩니다.
사이킷 런에서는 preprocessing 모듈에 구현된 OneHotEncoding 클래스를 사용해 변환을 수행하면 됩니다. 사용방법은 다음과 같습니다.
[Python Code]
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder(handle_unknown='ignore')
encoder.fit(data[["shape"]].values)
encoder.categories_
df_dummy = pd.DataFrame(encoder.transform(data[["shape"]].values).toarray(), columns=["is_C", "is_D", "is_H", "is_S"])
data_prep = pd.concat([data, df_dummy], axis=1)
data_prep
[실행 결과]
no color shape number is_C is_D is_H is_S
0 1 B S 13 0.0 0.0 0.0 1.0
1 2 R D 9 0.0 1.0 0.0 0.0
2 3 R H 9 0.0 0.0 1.0 0.0
3 4 B C 3 1.0 0.0 0.0 0.0
4 5 B S 12 0.0 0.0 0.0 1.0
OneHotEncoder를 초기화할 때, 변환하려는 특성의 열 위치를 fit 메소드에 전달합니다. 이때, 전달되는 데이터의 형태는 반드시 array 형식의 데이터여야 합니다.
데이터프레임의 경우. values() 메소드를 사용하면 array 형식의 데이터로 만들 수 있습니다. 이 후 transform() 메소드를 사용해서 희소행렬을 만들고 이를 array 형태로 변환하기 위해 transform() 결과에 .toarray() 메소드를 사용해서 데이터 형식을 변환해 주었습니다.
희소행렬은 대량의 데이터셋을 저장할 때 효과적이며, 특히 배열에 0이 많이 포함되어 있는 경우에 유용합니다. 또한 대부분의 사이킷 런 함수들이 희소행렬을 지원하기 때문에 자주 사용할 수 있습니다. 원-핫 인코딩으로 더미변수를 만드는 데에 더 편리한 방법은 판다스의 get_dummies() 함수를 사용합니다. 이는 문자열 열만 변환하고 나머지 열은 그대로 사용합니다.
[Python Code]
pd.get_dummies(data["shape"])
[실행 결과]
C D H S
0 0 0 0 1
1 0 1 0 0
2 0 0 1 0
3 1 0 0 0
4 0 0 0 1
이렇게 원-핫 인코딩 방법에 대해서 살펴봤습니다. 끝으로 원-핫 인코딩을 사용할 때는 반드시 다중 공산성에 대한 문제를 고려해야 합니다.
다중 공산성(Mulit-collinearity)란, 특성 간의 상관관계가 높으면 역행렬을 계산하기 어려워져 수치적으로 불안정해짐을 의미하는데, 이 때문에 변수 간의 상관관계를 감소시키기 위해서는 원-핫 인코딩 배열에서 특성 열 하나를 삭제해야 합니다. 이를 쉽게 하기 위해서 get_dummies() 함수의 경우, drop_first라는 매개 변수를 True로 지정해서 첫 번째 열을 삭제할 수 있습니다.
반면, OneHotEncoder 객체에는 열을 삭제하는 변수가 없지만, 원하는 열 만을 지정해서 사용할 수 있기 때문에 위의 문제를 해결할 수 있습니다.
'Data > Data Science 📊' 카테고리의 다른 글
[Data Analysis] 통계 분석Ⅰ: 모집단 & 표본 (0) | 2024.07.31 |
---|---|
[Data Analysis] 데이터 전처리 Ⅱ: 정규화, 표준화 (0) | 2024.07.31 |
[Data Analysis] 데이터 자료구조 Ⅱ: R (0) | 2024.07.30 |
[Data Analysis] 데이터 자료구조 Ⅰ: Python (0) | 2024.07.30 |
[Data Analysis] 데이터의 이해 (0) | 2024.07.29 |