nominal category feature encoding

2019-06-29
machine learning

How to Deal with Categorical Data

How to encode categorical variables

대부분의 머신 러닝 알고리즘들은 몇몇의 트리 기반 알고리즘을 제외하고는 입력 데이터가 수치형 (numerical values)이여야지만 합니다. 그러므로 카테고리들도 숫자로 변환을 하여야 머신 러닝 알고리즘을 적용할 수 있습니다. 카테고리형 데이터들은 사람이 볼 때에는 쉽게 이해할 수 있지만 컴퓨터는 카테고리 데이터가 주는 정보를 사람처럼 이해할 수 없습니다. 예를들어 우리는 한국이 일본과 가깝다는 것을 알고 아르헨티나는 한국과 멀리 떨어져있다는 것을 알고있지만 컴퓨터의 입장에서는 한국, 일본, 아르헨티나는 그저 “나라”라는 카테고리 피쳐에 있는 3개의 다른 값들 입니다. 카테고리 피쳐에 contextual (문맥/상황적) 정보를 담아 숫자로 바꾸어 주는 전처리 작업에 따라 머신러닝 모델의 성능이 좌지우지 됩니다. 이번 포스팅은 순서 정보가 없는 명목형 카테고리 피쳐 (nominal categorical feature)를 숫자로 나타내는 몇가지 방법에 대해 category Encoders 패키지를 통하여 알아보고 이 패키지를 이용하여 scikit learn으로 pipeline을 만드는 방법까지 알아보겠습니다.

Types of Encoders

  • Classic Encoders: 많이 알고 있으며 자주 사용되는 인코더 들입니다. ordinal, one hot, binary, hashing이 있습니다. 이 부분은 아래에서 다루도록 하겠습니다.
  • Bayesian-type Encoders: Bayesian 인코더는 종속 변수의 정보를 사용하여 인코딩을 합니다. 원핫인코딩 방식처럼 카테고리 변수가 가지고 있는 값의 갯수만큼 피쳐를 생성하는 것이 아니라 카테고리 피쳐에 대응하는 딱 하나의 피쳐만을 생성하기 때문에 cardinality가 아주 높을 때 유용하게 사용됩니다. Bayesian 인코딩 방식에도 여러가지가 있습니다. James-Stein, M-estimator, Target encoding (mean encoding), Leave One Out encoding, Weight of Evidence 이 있는데 모두 category encoders 패키지에 구현이 되어 있습니다. 이 포스팅에서는 제가 사용했던 target encoding (mean encoding)과 Leave One Out encoding에 대해서 다루겠습니다.
  • Contrast Encoders: Contrast encoder들은 주로 명목형 카테고리 변수가 아닌 순서가 있는 카테고리 변수일 때 사용될 수 있습니다. 또한 contrast encoders는 선형 회귀 모형에서 주로 쓰이지만 머신 러닝 알고리즘에서는 쓰이지 않습니다. 각 방법에 대해 아래 간단하게 설명을 달았으나 아래에서 깊게 다루지는 않겠습니다. Contrast encoder의 자세한 설명은 이 웹사이트를 참조하세요.
    • Helmert : Helmert 인코딩은 카테고리 피쳐에 순서가 있을 때 사용될 수 있습니다. 한 카테고리 값의 “레벨” (순서가 있는 경우)의 종속 변수의 평균이 전 레벨들의 종속 변수 (타겟)값의 평균과 비교되어 표현되는 인코딩 방법 입니다.
    • Sum : sum 인코딩 또한 Helmert 인코딩처럼 카테고리 피쳐에 순서가 있을 때 사용될 수 있습니다. 한 카테고리 값 “레벨”의 종속 변수의 평균을 모든 다른 “레벨”들의 종속 변수의 평균과 비교합니다.
    • Polynomial : Polynomial 인코딩은 특히 순서가 있는 카테고리 변수일 뿐 아니라 그 변수가 가지고 있는 값들의 간격이 일정할 때 사용합니다. 카테고리 값의 갯수에 따라 카테고리 값의 트랜드를 찾습니다. 선형 회귀 모델에서 카테고리 변수에서 트랜드를 찾을 때 주로 사용되는 방법입니다.

1. The issue of high cardinality and one hot encoding

탄자니아 식수사업 데이터를 분석하는 과정에서 처음으로 명목형 카테고리 피쳐들의 high cardinality 문제를 맞딱드렸습니다. 카테고리 피쳐에 있어 high cardinality란 한 카테고리 피쳐 안에 있는 unique한 값의 갯수가 높다는 것을 의미합니다. 아래에 보면 제가 가지고 있는 탄자니아 식수사업 데이터 셋 안에 있는 “funder”, “installer”, “subvillage” 피쳐에는 unique한 값이 천개가 훨씬 넘어간다는 것을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
11
# load Tanzania water pump dataset
df = pd.read_csv('./data/training.csv')
target = pd.read_csv('./data/labels.csv')

# find out how many unique values are in the chosen categorical features
high_cardinality_cols = [("funder" ,df.funder.nunique()),\
("installer", df.installer.nunique()),\
("subvillage", df.subvillage.nunique())]

for col in high_cardinality_cols:
print("the number of unique values in {} categorical feature : {}".format(col[0], col[1]))
the number of unique values in funder categorical feature : 1897
the number of unique values in installer categorical feature : 2145
the number of unique values in subvillage categorical feature : 19287

전체 데이터 갯수가 59,400인데 우리가 흔히 알고 있으며 많이 쓰이는 원핫인코딩 (one hot encoding) 방식으로 모든 카테고리 피쳐들을 전처리 하게 되면 피쳐의 갯수가 상상을 초월할 정도로 많아질 것이라는 것을 알 수 있습니다. 왜냐하면 원핫인코딩 방식은 각 카테고리 값마다 피쳐를 하나씩 생성하기 때문입니다. 이렇게 funder와 installer를 원핫인코딩하여 컬럼갯수를 세어보면 두 피쳐만 넣었는데도 이미 피쳐의 갯수가 4042라는 것을 확인할 수 있습니다.

1
len(pd.get_dummies(df[["funder", "installer"]]).columns)
4042

원핫인코딩의 장점은 무엇보다 이해하기 쉽고 구현도 쉽다입니다. 또한 한 카테고리 피쳐 안과 (within each category) 카테고리 피쳐들 사이가 (between categories) 가 다 분리되어 있기 때문에 카테고리 사이의 관계에 대한 가정이 없어서 모델에 따라 cardinality가 낮은 경우에는 머신러닝 모델의 성능이 잘 나오기도 합니다.

단점은 위에서 보는 것과 같이 cardinality가 클 때 아주 많은 피쳐(column)를 생성하기 때문에, 메모리 이슈가 생겨 모델 학습이 되지 않거나, 느려지게 합니다. 또한 트리 기반 알고리즘을 적용할 때 원핫인코딩으로 카테고리 값들을 모두 binary 변수로 바꿔주는 것은 좋지 않습니다. 이 이유에 대해서는 따로 포스팅을 하도록 하겠습니다.

2. Classic Encoding Methods

2-1 Ordinal Encoding

ordinal encoding은 카테고리 변수에 있는 값을 정수로 변환해 줍니다. 예를들어 “색”이라는 카테고리 피쳐가 있고, 그 피쳐안에 빨강, 파랑, 초록 이라는 string 값이 있고, 이 값들이 데이터에 나타나는 순서대로 숫자를 지정해 줍니다. 아래를 보면 빨강, 파랑, 초록의 순서대로 값이 등장했기 때문에, 빨강에는 1, 파랑에는 2, 초록에는 3 이라는 값이 지정되었음을 확인할 수 있습니다.

1
2
3
4
5
6
7
8
9
10
import category_encoders as ce

# Crate color category feature
colors = pd.DataFrame({
'color':["빨강", "노랑", "파랑", "보라", "초록", "파랑", "파랑", "초록", "빨강", "노랑"]})

# Utilize Ordinal Encoder of category_encoders package to encode color feature with 'fit_transform'
ce_ord = ce.OrdinalEncoder(cols = ['color'])
colors["ordinal_colors"] = ce_ord.fit_transform(colors['color'])
colors
color ordinal_colors
0 빨강 1
1 노랑 2
2 파랑 3
3 보라 4
4 초록 5
5 파랑 3
6 파랑 3
7 초록 5
8 빨강 1
9 노랑 2

만약 카테고리 값들이 정말 순서가 있는 값이고 그 순서의 간격이 일정하다면, ordinal encoded된 값을 그대로 사용하는 것이 괜찮을 수 있습니다. 예를들어 카테고리 피쳐의 값이 등수여서 “첫번째”, “두번째”, “세번째” 라는 값을 가지고 있는 경우입니다. 하지만, 대부분의 경우 명목형 카테고리 변수 이거나 순서가 있는 값을 가진 카테고리 변수지만 그 사이 간격이 모호한 경우 (예를들어, Excellent, Good, Fair, Poor)에는 아래에서 소개되는 다른 인코딩 방법을 포함하여 다른 인코딩 방법을 사용해야 합니다.

2-2 Hashing

Hashing Encoder는 해싱 트릭(hasing trick)을 적용합니다. 해시 함수는 컴퓨터 사이언스에서 기본이 되는데 아주 다양한 형태의 해시 함수가 존재 합니다. 하지만 모든 해시 함수는 딱 하나의 목적을 가지고 있습니다. 모호한 크기의 데이터를 정해진 크기의 데이터로 매핑 (map) 시킵니다. 그리하여 해싱을 적용하면 처음부터 그 카테고리 피쳐에 대한 0 벡터를 생성하게 됩니다. 그래서 해싱은 주로 스팸 필터링 (spam filtering)을 할 때 많이 사용되어지는 방식이었습니다. 왜냐하면 Bag of Words 방법은 vocabulary size가 정해져 있기 때문에 새로운 단어를 맞딱드렸을 때 Out of Vocabulary (OOV) 이슈를 피할 수 없는 반면, 해싱 트릭을 사용하게 되면 처음부터 아주 크기가 큰 0 벡터 (예를 들어 $2^{28}$ 사이즈의 0 벡터)를 만들고 해시 함수를 통해 string값이 통과되면 $0$ 부터 $2^{28}$ 사이의 값을 뱉어 냅니다. 그리하여 어떤 해시 함수를 사용하느냐에 따라 출력값과 출력값의 범위가 정해 지게 됩니다. 예를 들어 “빨강”이라는 값을 해시 함수에 통과시키면 특정한 해시값 (hash)을 인덱스 (키)로 갖게 되고 해당 인덱스에 원핫인코딩처럼 1을 표시합니다.

category encoders 패키지에 파라미터 값으로 n_components가 있는데 이 값이 0 벡터의 크기를 지정해 줍니다. 그리고 카테고리 변수에 있는 string값을 입력으로 받고 사용자가 n_components 로 지정한 범위 안에서 출력 값을 뱉어주는 해시 함수를 만들어 내거나 선택합니다. catetory encoders 패키지의 hashing encoder는 md5라는 해시 함수를 디폴트로 가지고 있습니다.

같은 입력 값을 해시 함수에 통과시킨다면, 항상 같은 출력값을 가지게 됩니다. 하지만 해시 충돌)로 인하여 정보의 손실이 있을 수 있습니다. 해시 충돌이란 다른 입력 값에 대해 같은 출력값을 가지는 상황을 말하는데 이렇게 같은 출력값을 가지게 되는 오버랩 (overlap)상황이 많지 않다면, 모델의 성능에 크게 영향을 주지는 않습니다. 아래는 1897개의 값을 가지고 있는 funder 카테고리 피쳐를 해싱 인코더를 사용하여 64차원으로 변형해보았습니다.

1
2
3
4
5
# Create a hashing encoder for the funder column with the number of components = 64
# default hash method is 'md5' user can create his/her own hash function and use that for hash encoder
hash_encoder = ce.HashingEncoder(cols = ['funder'], n_components = 64)
# transform the column with the above encoder
hash_encoder.fit_transform(df['funder']).head()
col_0 col_1 col_2 col_3 col_4 col_5 col_6 col_7 col_8 col_9 ... col_54 col_55 col_56 col_57 col_58 col_59 col_60 col_61 col_62 col_63
0 1 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
1 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
2 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
3 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0
4 0 0 0 0 0 0 0 0 0 0 ... 0 0 0 0 0 0 0 0 0 0

5 rows × 64 columns

2-3 Binary Encoding

바이너리 인코딩은 원핫인코딩과 해싱 인코딩을 섞어놓은 방법이라고 생각할 수 있습니다. 바이너리 인코딩은 원핫인코딩보다 적은 수의 피쳐를 생성해 내지만, 카테고리 피쳐가 가지고 있는 unique한 값들을 어느정도 보존 합니다. 바이너리 인코딩의 목적은 카테고리 변수가 가지고 있는 값의 갯수를 이진수로 바꾸어서 저장합니다. 만약 카테고리 변수에 10개의 값이 있다면, 이 값이 이진수로 변환되어 1010이 됩니다. 그리고 각 자리수가 4개의 분리된 열 (피쳐)이 됩니다. 그러므로 각 카테고리 값이 이진 형태로 열을 가로지르며 인코딩 됩니다. 아래는 위에서 생성한 “색” 피쳐로 바이너리 인코딩을 해 보았습니다.

1
2
3
# create binary encoder
ce_binary = ce.BinaryEncoder(cols = ['color'])
ce_binary.fit_transform(colors['color'])
color_0 color_1 color_2 color_3
0 0 0 0 1
1 0 0 1 0
2 0 0 1 1
3 0 1 0 0
4 0 1 0 1
5 0 0 1 1
6 0 0 1 1
7 0 1 0 1
8 0 0 0 1
9 0 0 1 0

바이너리 인코딩은 카테고리 피쳐의 cardinality가 높을 때 빛을 발합니다. 일단 원핫인코딩보다 훨씬 더 적은 수의 열을 생성하기 때문에, 메모리에 과부하가 가지 않으며 효율적입니다. 또한 cardinality가 높을 때 종종 일어나는 차원 문제를 해소시켜 주기도 합니다.

3. Bayesian-type Encoding Methods

3-2 Target (Mean) Encoding

타겟 인코딩은 카테고리 독립 변수의 각 값의 종속 변수의 평균값을 계산합니다. 이러한 방법은 비슷한 카테고리 끼리 비슷한 점이 인코딩 된다는 점과 classic한 인코딩 형식과 달리 계산 속도가 아주 빠른 것이 장점입니다. 예를 들어, 카테고리 변수 $X$ 가 있고 종속 변수 $Y$가 있다면 카테고리 변수 $X$ 에 있는 $x_i$ 값의 상응하는 값 $y_i$ 의 평균을 계산하고 이 값을 $x_i$ 대신으로 사용 합니다. Target encoding의 문제점은 overfitting되기 쉽다는 점입니다. 이러한 점을 방지하기 위해 smoothing factor가 들어가게 되는데, category encoders 패키지의 target_encoder에 smoothing 파라미터에 값을 넣어 regularization을 할 수 있습니다. 또한 min_samples_leaf 파라미터로 평균을 계산할 때 최소한의 데이터 사이즈를 정할 수 있습니다.

아래는 탄자니아 데이터셋으로 타겟 인코딩을 한 결과입니다. 타겟 인코딩을 하기 전, 타겟 값인 우물의 상태 status_group 는 label encoder로 변환 시켜주어야 하기에 scikit learn의 label encoder를 사용하여 타겟 값을 숫자로 변환시켜 줍니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# first, we need to label encode the target variable "status_group"
from sklearn.preprocessing import LabelEncoder
le = LabelEncoder()
df["status_group"] =target["status_group"]
df['status_group'] = le.fit_transform(df['status_group'])

# To make encoded values readable
pd.options.display.float_format = '{:.2f}'.format

# Target encoder with default parameters
ce_target = ce.TargetEncoder(cols = ['funder'])

# Show first 10 values
ce_target.fit_transform(df["funder"], df['status_group']).head(10)
funder
0 0.32
1 0.38
2 0.00
3 0.77
4 0.84
5 0.39
6 0.81
7 0.75
8 0.49
9 0.79

아래는 타겟 인코딩은 앞에서 언급한 두개의 파라미터 min_samples_leafsmoothing에 값을 넣어 변환시켜 봅니다.

1
2
3
4
5
# Target with min_samples_leaf = 10 and smoothing = 3
ce_target_min_leaf_10 = ce.TargetEncoder(cols = ['funder'], min_samples_leaf = 10, smoothing = 3)

# show first 10 values
ce_target_min_leaf_10.fit_transform(df["funder"], df['status_group']).head(10)
funder
0 0.32
1 0.38
2 0.56
3 0.77
4 0.84
5 0.39
6 0.81
7 0.75
8 0.49
9 0.80

3-1 Leave One Out Encoding

Leave One Out 인코딩은 카테고리 독립 변수의 각 값의 종속 변수의 평균값을 계산한다는 점에서 타겟 인코딩과 같습니다. 하지만 Leave One Out이라는 이름은 training과 test data set에 적용되는 알고리즘이 조금 다르기 때문에 붙여진 이름입니다. training data set에는 현재 데이터 (record 또는 row)의 종속 변수 값을 제외하고 계산하여 아웃라이어의 영향을 감소 시키려 합니다. 또한 overfitting 방지를 위해 가우시안 정규 분포 노이즈를 인코딩 된 값에 training할 때 더해 줄 수 있습니다 (testing data는 그대로 둡니다). 대부분 0.05에서 0.6 사이의 값을 지정해 줍니다. category encoders패키지의 leave one out에서는 이 값이 sigma라는 파라미터로 지정되어 있으며 디폴트는 ‘None’입니다.

1
2
3
# Leave One Out encoder with the sigma value of 0.1
ce_loo = ce.LeaveOneOutEncoder(cols = ['funder'])
ce_loo.fit_transform(df["funder"], df['status_group'], sigma = 0.3).head(10)
funder
0 0.32
1 0.38
2 0.00
3 0.77
4 0.84
5 0.39
6 0.81
7 0.75
8 0.48
9 0.85

Make a Prediction Model Utilizing Scikit Learn Pipeline

이제 직접 탄자니아 데이터셋을 가지고 위에서 언급한 카테고리 피쳐 인코딩 방식들 중 Leave One Out 인코더와 Binary 인코더를 사용하여 카테고리 변수들을 인코딩 하고 logistic regression 예측모델을 만들어 classifiation report까지 만들어 보도록 합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# import data preprocessing file 
from data_preprocessing_final import *

# load Tanzania water pump dataset
df = pd.read_csv('./data/training.csv')
target = pd.read_csv('./data/labels.csv')

# data preprocessing
df = make_meta(df)

df = df.merge(target, on='id', how = 'outer')

# only predict with 2 classes
df_modified = df[df.status_group != 'functional needs repair']
1
2
3
4
5
6
7
8
9
10
11
from sklearn.model_selection import train_test_split
from sklearn.preprocessing import LabelEncoder
# label encoding target variable
le = LabelEncoder()
df_modified['status_group'] = le.fit_transform(df_modified['status_group'])
df_modified.drop('id', axis=1, inplace = True)

# train test split
X_train, X_test, y_train, y_test = train_test_split(df_modified.iloc[:,:-1], df_modified.iloc[:,-1],
stratify=df_modified.iloc[:,-1],
test_size=0.20, random_state = 42)

Scikit learn의 make_column_transformer를 사용하여 왼쪽에는 리스트 안에 피쳐를 지정해 주고, 오른쪽에는 지정한 피쳐들에 사용하고자 하는 함수/ 인코더를 적어 줍니다.

make_column_transformer([피쳐들], 왼쪽에 선택된 피쳐들에 사용하고자 하는 함수/ 인코더)

아래는 완성된 make_column_transformer 입니다. 가지고 있는 피쳐들이 모두 카테고리 변수이기 때문에 특정한 인코딩을 지정한 이유는 없습니다. trial and error로 가장 성능이 높은 방식을 선택했습니다.

1
2
3
4
5
6
7
8
9
from sklearn.compose import make_column_transformer
import category_encoders as ce

preprocess = make_column_transformer(
(['extraction_type_class','permit','waterpoint_type','funder', 'installer','clustered_space','quantity',\
'season','construction_year'], ce.LeaveOneOutEncoder(sigma=0.05, random_state =42)),
(['basin', 'management','water_quality','source_class','amount_tsh','gps_height','payment','public_meeting'],\
ce.BinaryEncoder())
)

pipeline을 만들어 주고 logistic regression 모델까지 넣어준 후, classification report를 출력합니다.

1
2
3
4
5
6
from sklearn.pipeline import make_pipeline
from sklearn.linear_model import LogisticRegression

model = make_pipeline(
preprocess,
LogisticRegression(penalty='l1'))
1
model.fit(X_train, y_train)
Pipeline(memory=None,
         steps=[('columntransformer',
                 ColumnTransformer(n_jobs=None, remainder='drop',
                                   sparse_threshold=0.3,
                                   transformer_weights=None,
                                   transformers=[('leaveoneoutencoder',
                                                  LeaveOneOutEncoder(cols=None,
                                                                     drop_invariant=False,
                                                                     handle_missing='value',
                                                                     handle_unknown='value',
                                                                     random_state=42,
                                                                     return_df=True,
                                                                     sigma=0.05,
                                                                     verbose=0),
                                                  ['extraction_type_class...
                                                   'source_class', 'amount_tsh',
                                                   'gps_height', 'payment',
                                                   'public_meeting'])],
                                   verbose=False)),
                ('logisticregression',
                 LogisticRegression(C=1.0, class_weight=None, dual=False,
                                    fit_intercept=True, intercept_scaling=1,
                                    l1_ratio=None, max_iter=100,
                                    multi_class='warn', n_jobs=None,
                                    penalty='l1', random_state=None,
                                    solver='warn', tol=0.0001, verbose=0,
                                    warm_start=False))],
         verbose=False)
1
2
res = model.predict(X_test)
print(classification_report(res, y_test))
              precision    recall  f1-score   support

           0       0.90      0.79      0.84      7347
           1       0.66      0.82      0.73      3670

    accuracy                           0.80     11017
   macro avg       0.78      0.80      0.79     11017
weighted avg       0.82      0.80      0.80     11017

이렇게 해서 여러가지 카테고리 인코더들을 간단하게 알아보고, category encoder를 파이프라인에 적용시켜 모델을 학습시키고 예측까지 하였습니다. 다양한 카테고리 인코더 방식을 사용하여 이제 원핫인코딩 방식에서 벗어나 효율적인 카테고리 인코딩을 하시기 바랍니다.

Happy Learning!