
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() 함수를 사용해서 찾아내면 된다.
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]}")
# 결측치 탐색
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 로 설정하면 된다.
# 결측치가 있는 행 제거
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
# 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인 경우에는 아래와 같은 방법으로 한번에 제거해주는 것이 좋다.
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
# 모든 결측치를 가진 행 제거
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.2 누락된 값의 대체
앞서 살펴본 데로 누락된 값에 대해 삭제를 하는 방법이 있지만, 특정 샘플만 삭제하거나, 특정 열을 통째로 삭제하기가 어려운 경우가 있다. 누락된 값 때문에 주요한 혹은 유용한 데이터까지 삭제하는 것부터가 손해인 경우가 많기 때문이다. 이럴 경우 누락된 값에 대해 특정 값으로 대체하는, 보간 기법을 이용하면 큰 손실 없이 누락된 값에 대한 처리가 가능하다. 가장 흔하게 사용되는 보간 기법으로는 누락된 값을 평균 값으로 대체하는 방법이다. 각 특성 열의 전체 평균을 누락된 값이 존재하는 경우 대체하는 방법이며, 구현하면 다음과 같다.
# 평균값으로 대체
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
# 평균값으로 대체
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 클래스로 구현했다. 사용법은 아래와 같다.
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 순서로 되어야한다고 가정했을 때, 범주형의 문자값을 숫자로 변환해야 순서를 정하는 것이 좀 더 편리할 것이다. 하지만, 위의 내용대로 만들어 주는 함수가 없기에, 아래의 코드와 같이 직접 구현해보자.
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의 경우에는 아래 코드에서처럼 벡터에 값에 대응하는 이름을 지정할 수 있기 때문에 파이썬보다는 조금 더 간편하게 치환하는 것이 가능하다.
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() 함수를 사용해 데이터 전체에 적용시킬 수 있다. 참고로, 나중에 정수 값을 다시 문자열로 매핑하고 싶다면 아래의 코드를 추가해주면 된다.
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 부터 채워 나가는 것이다.
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}
다음으로 위의 내용으로 클래스 레이블을 변형한다.
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 클래스를 사용하면 되고, 사용 방법은 다음과 같다.
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() 메소드를 사용해서 정수 레이블을 문자열로 변환할 수 있다.
labeler.inverse_transform(y)
[실행 결과]
array(['S', 'D', 'H', 'C', 'S'], dtype=object)
2.2 순서가 없는 특성
지금까지는 딕셔너리 매핑 방식으로 순서를 가진 범주형 특성을 처리하는 방법을 사용했으며, 파이썬의 경우 사이킷런에서 LabelEncoder 를 이용해서 간편하게 문자열을 정수형으로 변환하였다. 순서 없는 컬럼에도 비슷한 방식을 사용할 수 있다. 위의 데이터 중 color 에 적용해보자.
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글래스를 사용해 변환을 수행하면 된다. 사용방법은 다음과 같다.
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() 함수를 사용하는 것이다. 이는 문자열 열만 변환하고 나머지 열은 그대로 사용한다.
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 Science > 데이터 분석 📊' 카테고리의 다른 글
[데이터분석] 6. 통계 분석Ⅰ: 모집단 & 표본 (0) | 2024.07.31 |
---|---|
[데이터 분석] 5. 데이터 전처리 Ⅱ: 정규화, 표준화 (0) | 2024.07.31 |
[데이터분석] 3. 데이터 자료구조 Ⅱ: R (0) | 2024.07.30 |
[데이터 분석] 2. 데이터 자료구조Ⅰ: Python (0) | 2024.07.30 |
[데이터분석] 1. 데이터의 이해 (0) | 2024.07.29 |