Categorical Data Encoding 방법

Joshua Kim
30 min readNov 5, 2024

--

Disclaimer

본 연구는 조윤호 교수님(Machine Learning, Deep Learning, KAIST. Ph.D.) 연구 수업에서 진행한 내용 요약입니다. (요약 과정에서 이론과 논리의 오류가 나타날 수 있으며 교수님의 연구 방향과 무관합니다.)

Abstract

“이 글은 머신러닝 모델에서 카테고리 데이터를 수치형으로 변환하는 다양한 인코딩 기법에 대해 설명합니다. 주요 방법으로는 One Hot, Label, Ordinal, Helmert, Binary, Frequency, Mean, Weight of Evidence, Probability Ratio, Hashing 등이 있으며, 각 기법의 특징과 장단점이 소개됩니다. 또한, 각 인코딩 방식이 모델 학습에 미치는 영향을 비교하며, Logistic Regression과 MLPClassifier를 사용해 테스트셋에서 성능을 평가한 결과도 포함하고 있습니다. WoE와 Mean 인코딩이 상대적으로 좋은 성능을 보였으며, Hashing과 Frequency 인코딩은 효과가 낮았습니다.”

Table of Contents

1. 들어가는 글
2. Categorical Data Type
3. Categorical Data Encoding
3.1. One Hot Encoding
3.2. Label Encoding
3.3. Ordinal Encoding
3.4. Helmert Encoding
3.5. Binary Encoding
3.6. Frequency Encoding
3.7. Mean Encoding
3.8. Weight of Evidence Encoding
3.9. Probability Ratio Encoding
3.10. Hashing
4. 각 인코딩 방식의 학습 효과 비교
4.1. 소스 코드
4.2. Testset Accuracy: Predicted vs. Accuracy
References

1. 들어가는 글

ML/DL에서 모델이 잘 학습되기 위해 가장 중요한 단계 중 하나는 Data Preprocessing입니다. 문제에 맞는 알고리즘을 선택하고 Hyperparameters를 튜닝하는 것도 물론 중요하지만, 그 이전에 모델이 주어진 데이터셋의 맥락을 수치적으로 이해할 수 있도록 사전 가공하는 것이 필요합니다.

Data preprocessing in the machine learning process

특히, 데이터셋에 Categorical Data Type을 지닌 Features가 있다면 더욱 중요한데요. 모델은 기본적으로 모든 데이터를 Numeric Values로 처리하기 때문에, 카테고리를 수치형 값으로 변환하는 과정에서 그 의미를 정확하게 반영할 수 있도록 연구자의 판단 개입이 필요하기 때문입니다.

2. Categorical Data Type

카테고리 유형의 데이터는 크게 두 가지로 나눌 수 있습니다.

A. Ordinal Data: 카테고리가 계층적 구조를 가집니다. 예를 들어, 성적, 등급, 직책 등이 있습니다.

B. Nominal Data: 카테고리 간에 계층적 구조가 없습니다. 예를 들어, 도시, 부서, 성별 등이 있습니다.

3. Categorical Data Encoding

그럼, Categorical Data Encoding이란 무엇일까요? Categorical Data Encoding은 Data Preprocessing 단계에서 카테고리 유형을 수치형 값으로 변환하는 과정을 의미합니다. 그래서 “인코딩”이라고 표현하는 것입니다. 제가 조사한 대표적인 10개의 인코딩 방법을 나열해보겠습니다.

3.1. One Hot Encoding

One Hot Encoding은 각 카테고리를 Unit Vector로 표현하는 방법입니다. 즉, 각 벡터의 차원은 각 카테고리의 해당 여부를 나타냅니다. 특히 카테고리 유형이 Nominal Data라면 인코딩 시 Hierarchicalize하지 않는다는 측면에서 좋은 방법이 될 수 있습니다.

Building a One Hot Encoding Layer with TensorFlow
https://byjus.com/maths/unit-vector/

저는 Pandas의 .get_dummies()를 사용해 다음과 같이 구현했습니다.

if type == 0:  # One Hot Encoding
return pd.get_dummies(traintest_input, columns=traintest_input.columns)

사실 One Hot Encoding과 Dummy Encoding 방식은 약간의 차이가 있습니다. One Hot Encoding은 오로지 Unit Vector 인코딩만을 허용하는 반면, Dummy Encoding은 Zero Vector까지 허용하여 차원이 하나 줄어드는 장점이 있습니다.

7 Must-know Techniques For Encoding Categorical Feature

따라서 Dummy Encoding은 인코딩 후의 차원을 1만큼 감소시킬 수 있습니다. 하지만 이미 Features 수가 방대한 데이터셋이라면 차원 1 감소의 효과가 얼마나 좋은 영향을 끼칠지는 명확하지 않을 것입니다.

3.2. Label Encoding

Label Encoding은 각 고유한 카테고리에 고유한 Integer 값을 Sequential하게 할당하는 방법입니다. 하지만, Label Encoder가 임의로 각 카테고리에 Integer 값을 할당하므로 Hierarchy가 왜곡되는 위험성이 존재합니다.

저는 다음과 같이 Scikit Learnd의 LabelEncoder를 통해 인코딩했습니다. 즉, 임의로 Integers로 Fitting한 후, Transform을 통해 변환을 진행했습니다.

elif type == 1:  # Label Encoding
for col in train_input.columns:
traintest_input[col + '_labeled'] = LabelEncoder().fit_transform(traintest_input[col])
return traintest_input.iloc[:, train_input.shape[1]:]

3.3. Ordinal Encoding

Label Encoding과 비슷한 접근이지만, Ordinal Encoding은 연구자가 직접 Integers 값을 할당합니다. 즉, Ordinal Data인 경우 직접 Hierarchy를 반영할 수 있는 것입니다.

저는 다음과 같이 Ordinal Encoding Mappers를 사전에 정의하여 구현했습니다.

ordinal_encoding_mappers = {
'bin_3': {'F': 1, 'T': 2},
'bin_4': {'N': 1, 'Y': 2},
'nom_0': {'Red': 1, 'Blue': 2, 'Green': 3},
'nom_1': {'Triangle': 1, 'Polygon': 2, 'Trapezoid': 3, 'Circle': 4, 'Square': 5, 'Star': 6},
'nom_2': {'Hamster': 1, 'Axolotl': 2, 'Lion': 3, 'Dog': 4, 'Cat': 5, 'Snake': 6},
'nom_3': {'India': 1, 'Costa Rica': 2, 'Russia': 3, 'Finland': 4, 'Canada': 5, 'China': 6},
'nom_4': {'Theremin': 1, 'Bassoon': 2, 'Oboe': 3, 'Piano': 4},
'ord_1': {'Novice': 1, 'Expert': 2, 'Contributor': 3, 'Grandmaster': 4, 'Master': 5},
'ord_2': {'Freezing': 1, 'Warm': 2, 'Cold': 3, 'Boiling Hot': 4, 'Hot': 5, 'Lava Hot': 6},
'ord_3': {chr(i): i - ord('a') + 1 for i in range(ord('a'), ord('o') + 1)},
'ord_4': {chr(i): i - ord('A') + 1 for i in range(ord('A'), ord('Z') + 1)},
}

elif type == 2: # Ordinal Encoding
for col, mapping in ordinal_encoding_mappers.items():
traintest_input[col + '_ordinal'] = traintest_input[col].map(mapping)
return traintest_input.drop(list(ordinal_encoding_mappers.keys()), axis=1)

3.4. Helmert Encoding

Helmert Encoding는 각 카테고리를 직전 카테고리와의 차이를 비교하며 순차적으로 값을 할당하는 방식입니다. 이 방식은 실제로 각 카테고리 간의 차이를 순차적으로 반영하므로, Ordinal Data인 경우 맥락을 인코딩 값에 잘 반영한다는 장점이 있습니다.

저는 HelmertEncoder를 통해 다음과 같이 구현했습니다.

elif type == 3:  # Helmert Encoding
encoder = ce.HelmertEncoder(cols=traintest_input.columns, drop_invariant=True)
return encoder.fit_transform(traintest_input)

3.5. Binary Encoding

One Hot Encoding과 유사하지만, 차원의 저주를 방지하기 위해 각 카테고리를 이진수로 변환합니다.

Categorical Data Encoding Techniques

즉, 차원을 하나로 유지하고 Category Map에서의 각 Position을 이진수로 표현하는 것입니다. 이는 Feature 개수가 늘어나는 과정에서 차원의 저주를 방지할 수 있다는 장점이 있습니다.

저는BinaryEncoder 를 사용하여 Fit-Transform을 진행했습니다.

elif type == 4:  # Binary Encoding
encoder = ce.BinaryEncoder(cols=traintest_input.columns)
return encoder.fit_transform(traintest_input)

3.6. Frequency Encoding

각 카테고리의 출현 빈도를 이용하여 인코딩하는 방식입니다.

Categorical Data Encoding Techniques

이 방식의 장점은 실제 데이터를 통해 각 카테고리의 빈도나 특이성을 반영할 수 있다는 점입니다. 즉, 매우 드문 카테고리와 자주 나타나는 카테고리 간의 차이를 인코딩 과정에 그대로 반영함으로써 모델이 이러한 차이를 효과적으로 학습할 수 있습니다. 특히, 자연어 데이터의 경우 효과적일 수 있습니다.

저는 다음과 같이 .value_counts() 를 통해 구현했습니다.

elif type == 5:  # Frequency Encoding
for col in traintest_input.columns:
freq = traintest_input[col].value_counts() / len(traintest_input)
traintest_input[col + '_freq'] = traintest_input[col].map(freq)
return traintest_input.iloc[:, train_input.shape[1]:]

3.7. Mean Encoding

이 방식은 각 카테고리 별로 Target 변수의 평균값을 계산하여 인코딩합니다. 각 카테고리가 Target에 끼치는 맥락을 수치에 반영한다는 장점이 있지만, 주어진 데이터셋 Overfitting 문제의 소지가 있습니다.

저는 다음과 같이, 직접 평균을 계산하여 구현했습니다.

elif type == 6:  # Mean Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
mean_encode = train_inputtarget.groupby(col)['target'].mean()
traintest_input[col + '_mean'] = traintest_input[col].map(mean_encode)
return traintest_input.iloc[:, train_input.shape[1]:]

3.8. Weight of Evidence Encoding

WoE Encoding은 Binary Classification 문제에서 사용할 수 있는 방식으로, 이 방식 역시 각 카테고리와 Target과의 관계를 반영한다는 장점이 있습니다. 즉, Target의 Positive(1)와 Negative(0) 클래스 중 각 카테고리가 어느 쪽에 더 많은 증거를 제공하는지를 인코딩에 활용하는 것입니다. 다음과 같이 자연로그를 취함으로써 각 인코딩 값 간의 차이를 극명화하는 방식을 흔히 사용합니다.

Feature Engineering , Categorical Encoding — Weight of Evidence, Counts & Frequency

WoE 인코딩은 카테고리와 Target 사이의 관계성을 정량적으로 내재화할 수 있다는 장점이 있습니다. 하지만 데이터셋의 Target 클래스 중 한 쪽이 지나치게 적을 경우, WoE 값이 극단적으로 커지거나 작아질 수 있기 때문에 Backpropagation 중 미분값이 소실되어 학습이 잘 안되기 쉽다는 단점이 존재합니다.

저는 다음과 같이 직접 WoE를 계산하여 구현했습니다.

elif type == 7:  # Weight of Evidence Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
woe_df = train_inputtarget.groupby(col)['target'].mean()
woe_df = pd.DataFrame(woe_df).rename(columns={'target': 'good'})
woe_df['bad'] = np.clip(1 - woe_df['good'], 0.000001, None)
woe_df['woe'] = np.log(woe_df['good'] / woe_df['bad'])
traintest_input[col + '_woe'] = traintest_input[col].map(woe_df['woe'])
return traintest_input.iloc[:, train_input.shape[1]:]

3.9. Probability Ratio Encoding

이 방식은 WoE와 유사하지만, WoE와 달리 자연 로그 변환을 적용하지 않습니다. 즉, 극단적인 편향을 다소 줄일 수 있고 실제 Target 확률 자체를 내재화한다는 장점이 있습니다. 그러나 여전히 긍정 또는 부정 클래스 중 하나가 데이터셋에 적을 경우 학습이 잘 안되기 쉬울 수 있고, 혹은 각 카테고리와 Target 사이의 1차적인 상관성이 지나치게 적을 경우 각 인코딩 값 간의 차이가 거의 없기 쉽습니다.

저는 직접 Probability Ratio를 계산하여 구현했습니다.

elif type == 8:  # Probability Ratio Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
pr_df = train_inputtarget.groupby(col)['target'].mean()
pr_df = pd.DataFrame(pr_df).rename(columns={'target': 'good'})
pr_df['bad'] = np.clip(1 - pr_df['good'], 0.000001, None)
pr_df['pr'] = pr_df['good'] / pr_df['bad']
traintest_input[col + '_pr'] = traintest_input[col].map(pr_df['pr'])
return traintest_input.iloc[:, train_input.shape[1]:]

3.10. Hashing

이 방식은 Hash Function에 카테고리를 입력하여 고정된 범위 내의 Integer 벡터로 변환합니다. 그러나 카테고리를 무작위로 변환하기 때문에 각 카테고리 간의 관계나 Hierarchy, 그리고 Target과의 연관성을 반영하지 못하는 단점이 있습니다.

Machine Learning 50: Feature Hashing

저는 HashingEncoder를 통해 fit-transform하여 구현했습니다.

elif type == 9:  # Hashing Encoding
encoder = ce.HashingEncoder(cols=traintest_input.columns, drop_invariant=True)
return encoder.fit_transform(traintest_input)

4. 각 인코딩 방식의 학습 효과 비교

4.1. 소스 코드

실제 데이터셋을 불러온 후, 각 10가지 방식으로 인코딩한 후, Neural Network의 MLPClassifierLogisticRegression에 학습하여 Testset Prediction까지 진행해봤습니다.

4.1.1. Load Libraries

# Basics
import numpy as np
import pandas as pd

# Categorical Data Encoders
from sklearn.preprocessing import LabelEncoder
import category_encoders as ce

# MLP Classifier
from sklearn.neural_network import MLPClassifier
# Train & Configure Models
from sklearn.model_selection import (
train_test_split,
cross_validate,
StratifiedKFold
)

# Data Viz
import matplotlib.pylab as plt
import seaborn as sns
%matplotlib inline

# Ignore Warnings
import warnings
warnings.filterwarnings('ignore')

4.1.2. Load Data

input_folderpath = 'input'
output_folderpath = 'output'
fname_train, fname_test = 'train.csv', 'test.csv'

fpath_train = input_folderpath + '/' + fname_train
fpath_test = input_folderpath + '/' + fname_test

train_df = pd.read_csv(fpath_train)
test_df = pd.read_csv(fpath_test)

# Column `id` isn't required.
train_df.drop(
['id'],
axis=1,
inplace=True
)
test_df.drop(
['id'],
axis=1,
inplace=True
)

print('Train Dataset:', train_df.shape)
print('Test Dataset:', test_df.shape)

4.1.3. Split them into input and target

train_input = train_df.drop(
['target'],
axis=1,
inplace=False
)
train_target = train_df[['target']]
test_input = test_df

print('Train Dataset:', train_input.shape, train_target.shape)
print('Test Dataset:', test_input.shape, 'Test has no targets at the moment.')

4.1.4. Data Cleaning

(1) Fill the Nulls with Mode

train_modes = train_input.mode().iloc[0]

train_input = train_input.apply(lambda col: col.fillna(train_modes[col.name]))
test_input = test_input.apply(lambda col: col.fillna(train_modes[col.name]))

print('# of Nulls in Train Datasets:', train_input.isna().sum().sum())
print('# of Nulls in Test Datasets:', train_input.isna().sum().sum())

(2) Delete Columns with Too Many Categories

train_input.nunique()

train_input.drop(
['nom_5', 'nom_6', 'nom_7', 'nom_8', 'nom_9', 'ord_5'],
axis=1,
inplace=True
)
test_input.drop(
['nom_5', 'nom_6', 'nom_7', 'nom_8', 'nom_9', 'ord_5'],
axis=1,
inplace=True
)

print('Train Dataset:', train_input.shape, train_target.shape)
print('Test Dataset:', test_input.shape, 'Test has no targets at the moment.')

4.1.5. Categorical Variable Encoding

(1) Ordinal Encoding Mappers

ordinal_encoding_mappers = {
'bin_3': {'F': 1, 'T': 2},
'bin_4': {'N': 1, 'Y': 2},
'nom_0': {'Red': 1, 'Blue': 2, 'Green': 3},
'nom_1': {'Triangle': 1, 'Polygon': 2, 'Trapezoid': 3, 'Circle': 4, 'Square': 5, 'Star': 6},
'nom_2': {'Hamster': 1, 'Axolotl': 2, 'Lion': 3, 'Dog': 4, 'Cat': 5, 'Snake': 6},
'nom_3': {'India': 1, 'Costa Rica': 2, 'Russia': 3, 'Finland': 4, 'Canada': 5, 'China': 6},
'nom_4': {'Theremin': 1, 'Bassoon': 2, 'Oboe': 3, 'Piano': 4},
'ord_1': {'Novice': 1, 'Expert': 2, 'Contributor': 3, 'Grandmaster': 4, 'Master': 5},
'ord_2': {'Freezing': 1, 'Warm': 2, 'Cold': 3, 'Boiling Hot': 4, 'Hot': 5, 'Lava Hot': 6},
'ord_3': {chr(i): i - ord('a') + 1 for i in range(ord('a'), ord('o') + 1)},
'ord_4': {chr(i): i - ord('A') + 1 for i in range(ord('A'), ord('Z') + 1)},
}

(2) Encoder Function

def apply_encoding(type, train_input, test_input, train_target=None):

traintest_input = pd.concat([train_input, test_input])

if type == 0: # One Hot Encoding
return pd.get_dummies(traintest_input, columns=traintest_input.columns)

elif type == 1: # Label Encoding
for col in train_input.columns:
traintest_input[col + '_labeled'] = LabelEncoder().fit_transform(traintest_input[col])
return traintest_input.iloc[:, train_input.shape[1]:]

elif type == 2: # Ordinal Encoding
for col, mapping in ordinal_encoding_mappers.items():
traintest_input[col + '_ordinal'] = traintest_input[col].map(mapping)
return traintest_input.drop(list(ordinal_encoding_mappers.keys()), axis=1)

elif type == 3: # Helmert Encoding
encoder = ce.HelmertEncoder(cols=traintest_input.columns, drop_invariant=True)
return encoder.fit_transform(traintest_input)

elif type == 4: # Binary Encoding
encoder = ce.BinaryEncoder(cols=traintest_input.columns)
return encoder.fit_transform(traintest_input)

elif type == 5: # Frequency Encoding
for col in traintest_input.columns:
freq = traintest_input[col].value_counts() / len(traintest_input)
traintest_input[col + '_freq'] = traintest_input[col].map(freq)
return traintest_input.iloc[:, train_input.shape[1]:]

elif type == 6: # Mean Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
mean_encode = train_inputtarget.groupby(col)['target'].mean()
traintest_input[col + '_mean'] = traintest_input[col].map(mean_encode)
return traintest_input.iloc[:, train_input.shape[1]:]

elif type == 7: # Weight of Evidence Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
woe_df = train_inputtarget.groupby(col)['target'].mean()
woe_df = pd.DataFrame(woe_df).rename(columns={'target': 'good'})
woe_df['bad'] = np.clip(1 - woe_df['good'], 0.000001, None)
woe_df['woe'] = np.log(woe_df['good'] / woe_df['bad'])
traintest_input[col + '_woe'] = traintest_input[col].map(woe_df['woe'])
return traintest_input.iloc[:, train_input.shape[1]:]

elif type == 8: # Probability Ratio Encoding
train_inputtarget = pd.concat([train_input, train_target], axis=1)
for col in traintest_input.columns:
pr_df = train_inputtarget.groupby(col)['target'].mean()
pr_df = pd.DataFrame(pr_df).rename(columns={'target': 'good'})
pr_df['bad'] = np.clip(1 - pr_df['good'], 0.000001, None)
pr_df['pr'] = pr_df['good'] / pr_df['bad']
traintest_input[col + '_pr'] = traintest_input[col].map(pr_df['pr'])
return traintest_input.iloc[:, train_input.shape[1]:]

elif type == 9: # Hashing Encoding (Reference: https://contrib.scikit-learn.org/category_encoders/hashing.html)
encoder = ce.HashingEncoder(cols=traintest_input.columns, drop_invariant=True)
return encoder.fit_transform(traintest_input)

4.1.6. Fit Model & Predict

  • MLPClassifierLogistic Regression
for type in range(10):

model = MLPClassifier(
max_iter=50,
random_state=1234321,
early_stopping=True
)

# model = LogisticRegression(
# random_state = 1234321
# )

traintest_input_encoded = apply_encoding(
type,
train_input,
test_input,
train_target
)
train_input_encoded = traintest_input_encoded.iloc[:train_input.shape[0], :].reset_index(drop=True)
test_input_encoded = traintest_input_encoded.iloc[train_input.shape[0]:, :].reset_index(drop=True)

# Fit Model & Make Predictions
model.fit(train_input_encoded, train_target)
test_pred = model.predict_proba(test_input_encoded)[:, 1]

# Save Prediction Results
start_id = train_input.shape[0]
end_id = start_id + test_input.shape[0]
test_pred = pd.DataFrame(
{
'id': range(start_id, end_id),
'target': test_pred
}
)

test_pred.to_csv(
f'{output_folderpath}/test_prediction_{type}.csv',
index=False
)

4.2. Testset Accuracy: Predicted vs. Accuracy

4.2.1. Accuracy Results

필자 작성

4.2.2. 해석

(1) Logistic Regression is Better Performing.

우선 모델은 Logistic Regression이 MLPClassifier보다 더욱 뛰어난 성능을 보여줬습니다. Neural Network까지 활용할 정도로 데이터셋의 구조가 난해하지 않아 Overfitting 문제가 발생한 것으로 보입니다.

(2) WoE Encoding and Mean Encoding were the Best Performing.

실제 데이터셋의 각 Feature의 의미가 무엇인지 대외비였기 때문에 정확히 맥락을 알 수 없었지만, WoE와 Mean 인코딩 방식이 두 알고리즘에 있어서 모두 성능이 좋았습니다. 이는 데이터셋의 카테고리 유형 데이터가 실제 Target과의 연관성을 잘 반영하고 있으므로 이를 인코딩 과정 중 모델에게 잘 인지시켜주었기 때문인 것으로 보입니다.

(3) Hashing Encoding and Frequency Encoding were the Worst Performing.

Hashing의 경우 각 카테고리의 맥락을 전혀 고려하지 않았고, Frequency의 경우 각 카테고리의 빈도 자체가 Target과의 연관성에 무관할 가능성이 높으므로 모델 성능에 악영향을 끼쳤을 것입니다.

--

--

Joshua Kim
Joshua Kim

Written by Joshua Kim

Analytics Engineer | 🇰🇷🇺🇸🇹🇼

No responses yet