캐글의 플레이그라운드 대회 'Categorical Feature Encoding Challenge' compeition에 참가해 이진 분류 문제를 해결해보았다.
이 경진대회는 인위적으로 만든 데이터로 구성되어 있으며 각 feature와 타깃값의 의미를 알 수 없다는 특징을 가지고 있다. 또한 모든 데이터가 범주형이며, bin_으로 시작하면 이진 feature, nom_으로 시작하면 명목형 feature, ord_로 시작하면 순서형 feature이다. 타깃값도 범주형 데이터이며 0과 1 두개로 구성되어 있기에 이진분류 문제로 볼 수 있다.
import pandas as pd
# 데이터 경로
data_path = '/kaggle/input/cat-in-the-dat/'
train = pd.read_csv(data_path + 'train.csv', index_col='id')
test = pd.read_csv(data_path + 'test.csv', index_col='id')
submission = pd.read_csv(data_path + 'sample_submission.csv', index_col='id')
index_col은 불러올 DataFrame의 인덱스를 지정하는 파라미터로, 열 이름을 전달하면 해당 열을 인덱스로 지정한다.
training data가 어떻게 이루어져 있는지 살펴보기 위해 보통 train.head()를 사용하는데 이는 feature의 개수가 많을 경우 생략되어 출력되므로 train.head().T 로 행과 열의 위치를 바꿔서 볼 수 있다.
feature 요약표를 만들어 각 feature의 데이터 타입이 무엇인지, 결측값, 고유값의 개수 등을 살펴볼 수 있다.
def resumetable(df):
print(f'데이터 세트 형상: {df.shape}')
summary = pd.DataFrame(df.dtypes, columns=['데이터 타입'])
summary = summary.reset_index()
summary = summary.rename(columns={'index': '피처'})
summary['결측값 개수'] = df.isnull().sum().values
summary['고윳값 개수'] = df.nunique().values
summary['첫 번째 값'] = df.loc[0].values
summary['두 번째 값'] = df.loc[1].values
summary['세 번째 값'] = df.loc[2].values
return summary
resumetable(train)
이진 feature: bin_0 ~ bin_4
명목형 feature: nom_0 ~ nom_9
순서형 featue: ord_0 ~ ord_5
for i in range(6):
feature = 'ord_' + str(i)
print(f'{feature} 고윳값: {train[feature].unique()}')
마지막으로 일, 월, 타깃값 요약표 또한 살펴보도록 한다.
print('day 고윳값:', train['day'].unique())
print('month 고윳값:', train['month'].unique())
print('target 고윳값:', train['target'].unique())
카운트플롯으로 타깃값 분포를 살펴보아 데이터가 얼마나 불균형한지 파악한다. (수치형 데이터의 분포를 파악할 땐 주로 displot(), 범주형 데이터의 분포를 파악할 땐 countplot()을 사용한다.)
mpl.rc('font', size=15) # 폰트 크기 설정
plt.figure(figsize=(7, 6)) # Figure 크기 설정
# 타깃값 분포 카운트플롯
ax = sns.countplot(x='target', data=train)
ax.set(title='Target Distribution');
rectangle = ax.patches[0] # 첫 번째 Rectangle 객체
print('사각형 높이:', rectangle.get_height())
print('사각형 너비:', rectangle.get_width())
print('사각형 왼쪽 테두리의 x축 위치:', rectangle.get_x())
print('텍스트 위치의 x좌표:', rectangle.get_x() + rectangle.get_width()/2.0)
print('텍스트 위치의 y좌표:', rectangle.get_height() + len(train)*0.001)
def write_percent(ax, total_size):
'''도형 객체를 순회하며 막대 상단에 타깃값 비율 표시'''
for patch in ax.patches:
height = patch.get_height() # 도형 높이(데이터 개수)
width = patch.get_width() # 도형 너비
left_coord = patch.get_x() # 도형 왼쪽 테두리의 x축 위치
percent = height/total_size*100 # 타깃값 비율
# (x, y) 좌표에 텍스트 입력
ax.text(x=left_coord + width/2.0, # x축 위치
y=height + total_size*0.001, # y축 위치
s=f'{percent:1.1f}%', # 입력 텍스트
ha='center') # 가운데 정렬
plt.figure(figsize=(7, 6))
ax = sns.countplot(x='target', data=train)
write_percent(ax, len(train)) # 비율 표시
ax.set_title('Target Distribution');
이진 feature의 분포를 타깃값 별로 따로 그려본다. 범주형 feature의 타깃값 분포를 고윳값별로 구분해 그려보는 것은 특정 고윳값이 특정 타깃값에 치우치는지 확인할 수 있는 방법이다.
import matplotlib.gridspec as gridspec # 여러 그래프를 격자 형태로 배치
# 3행 2열 틀(Figure) 준비
mpl.rc('font', size=12)
grid = gridspec.GridSpec(3, 2) # 그래프(서브플롯)를 3행 2열로 배치
plt.figure(figsize=(10, 16)) # 전체 Figure 크기 설정
plt.subplots_adjust(wspace=0.4, hspace=0.3) # 서브플롯 간 좌우/상하 여백 설정
# 서브플롯 그리기
bin_features = ['bin_0', 'bin_1', 'bin_2', 'bin_3', 'bin_4'] # 피처 목록
for idx, feature in enumerate(bin_features):
ax = plt.subplot(grid[idx])
# ax축에 타깃값 분포 카운트플롯 그리기
sns.countplot(x=feature,
data=train,
hue='target',
palette='pastel', # 그래프 색상 설정
ax=ax)
ax.set_title(f'{feature} Distribution by Target') # 그래프 제목 설정
write_percent(ax, len(train)) # 비율 표시
교차 분석표는 범주형 데이터 2개를 비교 분석하는데 사용되는 표로, 각 범주형 데이터의 빈도나 통계량을 행과 열로 결합해놓은 표이다. 명목형 feature별 타깃값 1의 비율을 구하기 위해 crosstab()함수로 만든다.
pd.crosstab(train['nom_0'], train['target'])
고윳값별 타깃값 0과 1이 몇개인지를 나타내주는데, 비율로 표현하려면 normalize 파라미터를 추가해 정규화해준다. normalize 파라미터에 'index'를 전달하면 인덱스를 기준으로 정규화한다.
# 정규화 후 비율을 백분율로 표현
crosstab = pd.crosstab(train['nom_0'], train['target'], normalize='index')*100
crosstab
인덱스가 feature 이름(nom_0)으로 되어있으니 인덱스를 재설정해준다.
crosstab = crosstab.reset_index() # 인덱스 재설정
crosstab
위의 교차분석표를 함수로 만들어둔다.
def get_crosstab(df, feature):
crosstab = pd.crosstab(df[feature], df['target'], normalize='index')*100
crosstab = crosstab.reset_index()
return crosstab
crosstab[1]
def plot_pointplot(ax, feature, crosstab):
ax2 = ax.twinx() # x축은 공유하고 y축은 공유하지 않는 새로운 축 생성
# 새로운 축에 포인트플롯 그리기
ax2 = sns.pointplot(x=feature, y=1, data=crosstab,
order=crosstab[feature].values, # 포인트플롯 순서
color='black', # 포인트플롯 색상
legend=False) # 범례 미표시
ax2.set_ylim(crosstab[1].min()-5, crosstab[1].max()*1.1) # y축 범위 설정
ax2.set_ylabel('Target 1 Ratio(%)')
get_crosstab()과 plot_pointplot() 함수를 활용해 최종적인 그래프를 그린다.
def plot_cat_dist_with_true_ratio(df, features, num_rows, num_cols,
size=(15, 20)):
plt.figure(figsize=size) # 전체 Figure 크기 설정
grid = gridspec.GridSpec(num_rows, num_cols) # 서브플롯 배치
plt.subplots_adjust(wspace=0.45, hspace=0.3) # 서브플롯 좌우/상하 여백 설정
for idx, feature in enumerate(features):
ax = plt.subplot(grid[idx])
crosstab = get_crosstab(df, feature) # 교차분석표 생성
# ax축에 타깃값 분포 카운트플롯 그리기
sns.countplot(x=feature, data=df,
order=crosstab[feature].values,
color='skyblue',
ax=ax)
write_percent(ax, len(df)) # 비율 표시
plot_pointplot(ax, feature, crosstab) # 포인트플롯 그리기
ax.set_title(f'{feature} Distribution') # 그래프 제목 설정
nom_features = ['nom_0', 'nom_1', 'nom_2', 'nom_3', 'nom_4'] # 명목형 피처
plot_cat_dist_with_true_ratio(train, nom_features, num_rows=3, num_cols=2)
from pandas.api.types import CategoricalDtype
ord_1_value = ['Novice', 'Contributor', 'Expert', 'Master', 'Grandmaster']
ord_2_value = ['Freezing', 'Cold', 'Warm', 'Hot', 'Boiling Hot', 'Lava Hot']
# 순서를 지정한 범주형 데이터 타입
ord_1_dtype = CategoricalDtype(categories=ord_1_value, ordered=True)
ord_2_dtype = CategoricalDtype(categories=ord_2_value, ordered=True)
# 데이터 타입 변경
train['ord_1'] = train['ord_1'].astype(ord_1_dtype)
train['ord_2'] = train['ord_2'].astype(ord_2_dtype)
plot_cat_dist_with_true_ratio(train, ord_features,
num_rows=2, num_cols=2, size=(15, 12))
고윳값 순서에 따라 타깃값 1의 비율이 커진다는 것을 알 수 있다.
ord_4와 ord_5 또한 그래프를 그려보는데, 고윳값 개수가 많기에 가로 길이를 늘려 2행 1열로 그려준다.
plot_cat_dist_with_true_ratio(train, ['ord_4', 'ord_5'],
num_rows=2, num_cols=1, size=(15, 12))
date_features = ['day', 'month']
plot_cat_dist_with_true_ratio(train, date_features,
num_rows=2, num_cols=1, size=(10, 10))
import pandas as pd
# 데이터 경로
data_path = '/kaggle/input/cat-in-the-dat/'
train = pd.read_csv(data_path + 'train.csv', index_col='id')
test = pd.read_csv(data_path + 'test.csv', index_col='id')
submission = pd.read_csv(data_path + 'sample_submission.csv', index_col='id')
all_data = pd.concat([train, test]) # 훈련 데이터와 테스트 데이터 합치기
all_data = all_data.drop('target', axis=1) # 타깃값 제거
all_data
원-핫 인코더 객체를 생성하고 all_data의 모든 feature를 인코딩해 새롭게 저장해준다.
from sklearn.preprocessing import OneHotEncoder
encoder = OneHotEncoder() # 원-핫 인코더 생성
all_data_encoded = encoder.fit_transform(all_data) # 원-핫 인코딩 적용
인코딩을 공통으로 적용해주었으므로 다시 training data와 test data를 나눠준다. 추가로 타깃값을 y 변수에 저장해둔다.
num_train = len(train) # 훈련 데이터 개수
# 훈련 데이터와 테스트 데이터 나누기
X_train = all_data_encoded[:num_train] # 0 ~ num_train - 1행
X_test = all_data_encoded[num_train:] # num_train ~ 마지막 행
y = train['target']
training data 중 일부를 valid data(검증 데이터)로 나누어준다. 제출 전에 모델 성능을 평가해볼 수 있다.
from sklearn.model_selection import train_test_split
# 훈련 데이터, 검증 데이터 분리
X_train, X_valid, y_train, y_valid = train_test_split(X_train, y,
test_size=0.1,
stratify=y,
random_state=10)
선형 회귀 방식으로 분류를 수행하는 로지스틱 회귀 모델을 생성하고 훈련한다.
from sklearn.linear_model import LogisticRegression
logistic_model = LogisticRegression(max_iter=1000, random_state=42) # 모델 생성
logistic_model.fit(X_train, y_train) # 모델 훈련
타깃값 예측 메서드 predict()는 타깃값 자체, 0인지 1인지를 예측하고, predict_proba()는 타깃값의 확률, 0일 확률과 1일 확률을 예측한다. 첫번째 열은 타깃값이 0일 확률, 두번째 열을 1일 확률을 나타낸다.
logistic_model.predict_proba(X_valid)
본 대회에서는 타깃값이 1일 확률을 예측하는 것이므로 predict_proba()로 예측한 결과의 두번째 열을 타깃 예측값으로 사용한다. 검증 데이터를 활용해 타깃값을 예측할 수 있다.
# 검증 데이터를 활용한 타깃 예측
y_valid_preds = logistic_model.predict_proba(X_valid)[:, 1]
타깃 예측값인 y_valid_preds와 실제 타깃값 y_valid를 이용해 ROC AUC 점수를 구해 모델 성능을 검증한다.
from sklearn.metrics import roc_auc_score # ROC AUC 점수 계산 함수
# 검증 데이터 ROC AUC
roc_auc = roc_auc_score(y_valid, y_valid_preds)
print(f'검증 데이터 ROC AUC : {roc_auc:.4f}')
# 타깃값 1일 확률 예측
y_preds = logistic_model.predict_proba(X_test)[:, 1]
# 제출 파일 생성
submission['target'] = y_preds
submission.to_csv('submission.csv')
# 훈련 데이터와 테스트 데이터 합치기
all_data = pd.concat([train, test])
all_data = all_data.drop('target', axis=1) # 타깃값 제거
all_data['bin_3'] = all_data['bin_3'].map({'F':0, 'T':1})
all_data['bin_4'] = all_data['bin_4'].map({'N':0, 'Y':1})
ord_1과 ord_2 feature 또한 map()함수로 순서를 맞춰 인코딩해준다.
ord1dict = {'Novice':0, 'Contributor':1,
'Expert':2, 'Master':3, 'Grandmaster':4}
ord2dict = {'Freezing':0, 'Cold':1, 'Warm':2,
'Hot':3, 'Boiling Hot':4, 'Lava Hot':5}
all_data['ord_1'] = all_data['ord_1'].map(ord1dict)
all_data['ord_2'] = all_data['ord_2'].map(ord2dict)
ord_3, 4, 5는 알파벳 순서대로 인코딩을 해줘야 하는데, OrdinalEncoder를 사용하면 된다.
from sklearn.preprocessing import OrdinalEncoder
ord_345 = ['ord_3', 'ord_4', 'ord_5']
ord_encoder = OrdinalEncoder() # OrdinalEncoder 객체 생성
# ordinal 인코딩 적용
all_data[ord_345] = ord_encoder.fit_transform(all_data[ord_345])
# 피처별 인코딩 순서 출력
for feature, categories in zip(ord_345, ord_encoder.categories_):
print(feature)
print(categories)
먼저 list comprehension을 활용해 명목형 feature list를 만들어준다.
nom_features = ['nom_' + str(i) for i in range(10)] # 명목형 피처
원-핫 인코딩을 바로 all_data에서 하면 열 개수가 늘어나서 인코딩할 수 없기에 별도 행렬에 저장하고 이어서 all_data에서 명목형 feature를 삭제해준다.
from sklearn.preprocessing import OneHotEncoder
onehot_encoder = OneHotEncoder() # OneHotEncoder 객체 생성
# 원-핫 인코딩 적용
encoded_nom_matrix = onehot_encoder.fit_transform(all_data[nom_features])
encoded_nom_matrix
all_data = all_data.drop(nom_features, axis=1) # 기존 명목형 피처 삭제
date_features = ['day', 'month'] # 날짜 피처
# 원-핫 인코딩 적용
encoded_date_matrix = onehot_encoder.fit_transform(all_data[date_features])
all_data = all_data.drop(date_features, axis=1) # 기존 날짜 피처 삭제
encoded_date_matrix
min-max 정규화를 해주어 feature값의 범위를 0~1로 조정한다.
from sklearn.preprocessing import MinMaxScaler
ord_features = ['ord_' + str(i) for i in range(6)] # 순서형 피처
# min-max 정규화
all_data[ord_features] = MinMaxScaler().fit_transform(all_data[ord_features])
이진 feature, 순서형 feature가 인코딩되어 있는 all_data와 명목형 feature, 날짜 feature가 원-핫 인코딩되어 있는 encoded_nom_matrix, encoded_date_matrix를 모두 합친다.
두 matrix는 CSR 형식이므로 전달받은 데이터를 CSR 형식으로 바꿔주는 csr_matrix() 함수를 이용한다. hstack()은 행렬을 수평 방향으로 합친다.
from scipy import sparse
# 인코딩 및 스케일링된 피처 합치기
all_data_sprs = sparse.hstack([sparse.csr_matrix(all_data),
encoded_nom_matrix,
encoded_date_matrix],
format='csr')
training data와 test data를 나누어준다. y는 모델 훈련 시 필요한 타깃값(정답)이다.
num_train = len(train) # 훈련 데이터 개수
# 훈련 데이터와 테스트 데이터 나누기
X_train = all_data_sprs[:num_train] # 0 ~ num_train - 1행
X_test = all_data_sprs[num_train:] # num_train ~ 마지막 행
y = train['target']
training data를 다시 training data와 검증 data로 나누어준다.
from sklearn.model_selection import train_test_split
# 훈련 데이터, 검증 데이터 분리
X_train, X_valid, y_train, y_valid = train_test_split(X_train, y,
test_size=0.1,
stratify=y,
random_state=10)
그리드서치로 로지스틱 회귀 모델의 하이퍼파라미터를 최적화한다. 규제 강도를 조절하는 파라미터 C(값이 작을수록 규제 강도가 세짐)와 max_iter 하이퍼파라미터를 탐색한다. 평가지표는 ROC AUC로 지정한다.
%%time
from sklearn.model_selection import GridSearchCV
from sklearn.linear_model import LogisticRegression
# 로지스틱 회귀 모델 생성
logistic_model = LogisticRegression()
# 하이퍼파라미터 값 목록
lr_params = {'C':[0.1, 0.125, 0.2], 'max_iter':[800, 900, 1000],
'solver':['liblinear'], 'random_state':[42]}
# 그리드서치 객체 생성
gridsearch_logistic_model = GridSearchCV(estimator=logistic_model,
param_grid=lr_params,
scoring='roc_auc', # 평가지표
cv=5)
# 그리드서치 수행
gridsearch_logistic_model.fit(X_train, y_train)
print('최적 하이퍼파라미터:', gridsearch_logistic_model.best_params_)
검증 데이터로 모델 성능을 검증해보기 위해 타깃 예측값을 구하고, 이어 ROC AUC를 구한다.
y_valid_preds = gridsearch_logistic_model.predict_proba(X_valid)[:, 1]
from sklearn.metrics import roc_auc_score # ROC AUC 점수 계산 함수
# 검증 데이터 ROC AUC
roc_auc = roc_auc_score(y_valid, y_valid_preds)
print(f'검증 데이터 ROC AUC : {roc_auc:.4f}')
# 타깃값 1일 확률 예측
y_preds = gridsearch_logistic_model.best_estimator_.predict_proba(X_test)[:,1]
# 제출 파일 생성
submission['target'] = y_preds
submission.to_csv('submission.csv')
앞의 모델에서는 전체 training data를 9:1의 비율로 training data, valid data로 나누어 valid data는 검증용으로만 사용하였는데, 나누지 않고 training data 전체를 사용해 모델을 훈련한다면 성능이 향상된다. train_test_split()으로 나누는 부분을 삭제한다.
최종적으로 제출할 때 vaild data까지 포함한 전체 training data로 다시 훈련하여 제출하는 것이 성능 향상에 유리하다.
score: 0.80282로 1341명의 참가자 중 2위를 차지하였다.(2022.11.19 기준)
마지막에 제출할 때 valid data까지 모두 포함한 전체 training data로 모델을 다시 훈련해 제출한 부분에서 크게 등수를 올릴 수 있었기에 다른 대회에서도 최종 제출 전 이 방법을 적용해보면 좋을 것 같다.
github에 해당 코드를 올려두었다.
참고: 머신러닝·딥러닝 문제해결 전략 (캐글 수상작 리팩터링으로 배우는 문제해결 프로세스와 전략)
참고: https://www.kaggle.com/code/kabure/eda-feat-engineering-encode-conquer/notebook