Upstage AI lab에서 첫 번째 경진 대회를 진행하였다. 이런 미니 캐글 대회에 출전하는 것은 처음이었는데, 좋은 성적을 얻기 위해 정말 고민을 많이 했고, 배운 점이 많았다. 먼저 대회의 주제는 다음과 같았다.
House Price Prediction 경진대회는 주어진 데이터를 활용하여 서울의 아파트 실거래가를 효과적으로 예측하는 모델을 개발하는 대회입니다.
부동산은 의식주에서의 주로 중요한 요소 중 하나입니다. 이러한 부동산은 아파트 자체의 가치도 중요하고, 주변 요소 (강, 공원, 백화점 등)에 의해서도 영향을 받아 시간에 따라 가격이 많이 변동합니다. 개인에 입장에서는 더 싼 가격에 좋은 집을 찾고 싶고, 판매자의 입장에서는 적절한 가격에 집을 판매하기를 원합니다. 부동산 실거래가의 예측은 이러한 시세를 예측하여 적정한 가격에 구매와 판매를 도와주게 합니다. 그리고, 정부의 입장에서는 비정상적으로 시세가 이상한 부분을 체크하여 이상 신호를 파악하거나, 업거래 다운거래 등 부정한 거래를 하는 사람들을 잡아낼 수도 있습니다.
저희는 이러한 목적 하에서 다양한 부동산 관련 의사결정을 돕고자 하는 부동산 실거래가를 예측하는 모델을 개발하는 것입니다. 특히, 가장 중요한 서울시로 한정해서 서울시의 아파트 가격을 예측하려고 합니다.
대회가 시작되기 전에 data들과 baseline_code가 제공된다. 그리고 모델의 평가 점수는 RMSE(Root Mean Square Error)로 계산된다. local에서 돌렸을 때의 점수, public leaderboard, private leaderboard에서의 점수는 다 달랐다. 그래서 어느 지표를 보고 판단할지가 중요한 문제였는데 이는 나중에 더 자세히 얘기해 보겠다.
1. 데이터 전처리
먼저 데이터에 머신 러닝을 적용해 보기 전에 데이터의 전처리가 우선이다. 데이터를 살펴보면 다음과 같은 모양이다.
칼럼 목록은 다음과 같다.
Index(['번지', '본번', '부번', '아파트명', '전용면적', '계약일', '층', '건축년도', '도로명',
'k-단지분류(아파트,주상복합등등)', 'k-전화번호', 'k-팩스번호', 'k-세대타입(분양형태)', 'k-관리방식',
'k-복도유형', 'k-난방방식', 'k-전체동수', 'k-전체세대수', 'k-건설사(시공사)', 'k-시행사',
'k-사용검사일-사용승인일', 'k-연면적', 'k-주거전용면적', 'k-관리비부과면적', 'k-전용면적별세대현황(60㎡이하)',
'k-전용면적별세대현황(60㎡~85㎡이하)', 'k-85㎡~135㎡이하', 'k-수정일자', '고용보험관리번호',
'경비비관리형태', '세대전기계약방법', '청소비관리형태', '건축면적', '주차대수', '기타/의무/임대/임의=1/2/3/4',
'단지승인일', '사용허가여부', '관리비 업로드', '좌표X', '좌표Y', '단지신청일', 'target',
'is_test', '구', '동', '계약년', '계약월'],
dtype='object')
그리고 칼럼 별로 결측치 비율을 보면 다음과 같다.
결측치를 살펴보면 좌표 X랑 좌표 Y의 결측치가 거의 80% 가까이 되는 것을 볼 수 있다. 직관적으로 부동산 가격에 부동산의 위치가 큰 영향을 끼친다고 생각해서 이 결측치를 채우는 것을 우선했다.
먼저 외부데이터를 이용해서 결측치를 채웠다. 서울시 공동주택 아파트 정보 csv 파일을 이용해서 결측치를 채웠다. 도로명 주소는 중복되지 않기 때문에, 도로명 주소가 동일하다면 동일한 아파트라고 가정하고 좌표 X와 좌표 Y 값을 채웠다. 그중에는 도로명이 중복되는 칼럼이 존재했기에, 이는 이상치라 생각하고 중복되는 칼럼은 지워주었다.
그럼에도 불구하고 여전히 좌표 X와 좌표 Y가 비어있는 값들이 존재했다. 이러한 값들을 채우기 위해 Geocoding api를 이용해서 좌표 X와 좌표 Y를 채웠다.
그 결과 좌표 X와 좌표 Y의 결측치는 65개로 대폭 감소했는데, 전체 데이터 수가 백만 개인 것을 감안하면 효과적으로 결측치를 채웠다고 볼 수 있다.
그리고 결측치가 80만 개 이상인, 즉 결측치 비율이 80% 이상인 변수들을 모두 지우고 결측치 비율을 확인해 보면 다음과 같았다.
2. 파생변수
그 후에는 파생변수를 제작해서 모델의 성능 향상을 시도하였다. 먼저 baseline_code에는 강남에 있는지에 따라서 is_gangnam이라는 파생 변수를 제작하였다. is_gangnam 변수는 다음과 같은 기준으로 생성되었다.
gangnam = ['강서구', '영등포구', '동작구', '서초구', '강남구', '송파구', '강동구']
구 이름이 이 리스트에 포함되면 is_gangnam 값을 1로 설정하고, 포함되지 않으면 0으로 설정하여 강남 지역 여부를 구분하였다.
또한, 신축 여부가 결과에 큰 영향을 미친다고 판단하여 신축 여부를 판단하는 새로운 파생 변수를 생성하였다. 이를 위해 다음 코드를 활용하였다:
concat_select['건축년도'].describe(percentiles = [0.1, 0.25, 0.5, 0.75, 0.8, 0.9])
이 코드를 이용하면 2009년 이후에 지어진 건물이 10% 정도를 차지한다는 점을 알게 되어서 2009년 이후에 지어진 건물은 신축으로, 그전에 지어진 건물은 구축으로 분류하여 새로운 파생 변수를 제작하였다.
지금까지 진행한 파생 변수를 예시로 보면 위 개포6차우성 아파트는 강남구이기 때문에 강남 여부는 1, 건축년도는 1987년이기 때문에 신축 여부는 0이다.
여기까지가 기본적으로 제공된 파생 변수고, 이후에는 새로운 파생변수를 생각해 보았다. 새로운 파생변수를 생각하려면 일단 데이터의 분포를 더 자세히 알아야 할 필요가 있기 때문에 구별 평균가와 동별 평균가를 시각화해 보았다.
동 별 평균가는 동의 개수가 너무 많아서 의미를 부여하기 힘들다고 판단했고, 구 별 평균가를 보면서 아이디어가 떠올랐다. 구와 관련된 정보는 범주형 변수, 그중에서도 순서가 없는 명목형 변수이다. 그러나 구별 평균가가 확실히 차이가 있고, 구에 따른 분포도 다르기 때문에, 구가 부동산 가격에 큰 영향을 끼친다고 판단하였다.
이에 따라, 구별 평균가를 기반으로 새로운 파생 변수를 생성하였다. 강남구가 평균 가격이 가장 높으므로 강남구를 0으로, 송파구가 두 번째로 높으므로 송파구를 1로 설정하는 식으로 순위를 매겨 변수를 만들었다. 결과적으로, 아래와 같은 파생 변수를 도출할 수 있었다.
구 구_encoded
0 강남구 0
56850 서초구 1
85396 용산구 2
69482 송파구 3
60809 성동구 4
20613 광진구 5
49043 마포구 6
91498 중구 7
46676 동작구 8
75607 양천구 9
91346 종로구 10
81135 영등포구 11
4452 강동구 12
53021 서대문구 13
42975 동대문구 14
64667 성북구 15
10533 강서구 16
87444 은평구 17
16745 관악구 18
22444 구로구 19
8413 강북구 20
93439 중랑구 21
28698 노원구 22
26522 금천구 23
38549 도봉구 24
이렇게 적용을 했더니, 팀원 분께서 반대로 평균 가격이 높은 구일수록 숫자가 크고, 평균 가격이 낮을수록 숫자를 낮게 설정하는 방식이 결과가 더 좋게 나올 거 같다고 제안을 해주셔서 그 의견을 이용해서 반대로 적용했더니 결과가 조금 더 좋아졌다.
다음으로는 지하철 정보를 추가하고 싶었다. 부동산 가격 형성에 지하철 여부, 즉 역세권인지 아닌지가 큰 영향을 끼친다고 생각했다.
먼저 공공포털에서 지하철 정보를 가지고 있는 파일을 구해서 그 파일을 열어보니 다음과 같은 결과가 나왔다.
여기에서 일단 서울 소재 지하철만 구분한 후, 도로명주소를 이용해서 좌표 X와 좌표 Y를 구했다. 그 후에 지하철 역과 아파트의 좌표 X와 좌표 Y를 통해 최단 거리를 구한 후, 거리를 파생 변수로 추가하였다.
거리는 Haversine Formula라는 경도와 위도를 구할 때 사용하는 공식을 이용해서 구했다.
지하철 역 파생 변수를 적용해서 17400의 rmse 점수가 16200까지 줄어든 효과를 볼 수 있었다.
또한, 아파트 이름에 따른 부동산 가격의 변화가 있을 것이라고 생각했다. 아파트 이름이 좋다고 부동산 가격이 높은 것은 아니지만, 가격이 높은 아파트들의 이름은 영어나 고급스러운 어휘를 사용할 것이라고 생각했다. 실제로 최근에 지어진 고급 아파트들을 조사한 결과, 더퍼스트, 센트럴, 포레 등의 이름이 자주 사용되고 있었다. 그래서 위 이름들을 포함한 아파트들을 따로 파생변수로 제작하는 방법을 이용했다.
# 아파트명에서 키워드를 찾아 카테고리 열 생성
categories = ['더퍼스트', '센트럴', '파크', '포레', '메트로', '에듀', '시티']
for category in categories:
concat_select[category] = concat_select['아파트명'].str.contains(category)
# '리버'와 '레이크'를 하나의 카테고리 '리버레이크'로 보기
concat_select['리버 or 레이크'] = concat_select['아파트명'].str.contains('리버|레이크')
# '기타' 카테고리 열 생성
concat_select['기타'] = ~concat_select[categories + ['리버 or 레이크']].any(axis=1)
# '카테고리' 열을 범주형으로 변환
concat_select['카테고리'] = concat_select[categories + ['리버 or 레이크', '기타']].idxmax(axis=1)
# 카테고리별 평균 실거래가 계산
category_mean_target = concat_select.groupby('카테고리')['target'].mean().reset_index()
category_mean_target = category_mean_target.rename(columns={'target': '평균 실거래가'})
# 그래프 그리기
plt.figure(figsize=(10, 6))
sns.barplot(x='카테고리', y='평균 실거래가', data=category_mean_target)
plt.title('각 카테고리별 평균 실거래가 비교')
plt.xlabel('카테고리')
plt.ylabel('평균 실거래가')
plt.show()
columns_to_drop = categories + ['리버 or 레이크', '기타']
concat_select.drop(columns=columns_to_drop, inplace=True)
그러나 이 방법이 점수에 거의 영향을 끼치지 않았기 때문에, 위 방법은 사용하지 않았다.
다음으로, 재건축 가능 여부가 부동산 가격 형성에 중요한 역할을 할 것이라고 판단하였다. 일반적으로 재건축이 가능한 시점은 건축 연도에서 30년 이상 경과한 경우이기 때문이다. 이를 바탕으로, 계약 연도와 건축 연도의 차이가 30년 이상인 경우를 나타내는 새로운 파생 변수를 생성하여 데이터를 보완하였다.
또한 신축 년도를 몇 년으로 구분할지 확인하기 위해 3년과 5년 차이도 넣어서 코드를 돌려보았더니 성능 개선이 거의 일어나지 않았기 때문에 위 부분 또한 최종 결과에 포함하지 않았다.
concat_select['yrs_diff_built_contract'] = concat_select['계약년'] - concat_select['건축년도']
concat_select['built_in3yrs'] = concat_select['yrs_diff_built_contract'].apply(lambda x : 1 if x <= 3 else 0)
concat_select['built_in5yrs'] = concat_select['yrs_diff_built_contract'].apply(lambda x : 1 if x <= 5 else 0)
concat_select['built_over30yrs'] = concat_select['yrs_diff_built_contract'].apply(lambda x : 1 if x >= 30 else 0)
3. 모델 선정 및 하이퍼 파라미터 선정
데이터 전처리와 파생변수 생성 작업을 마친 후, 모델 선정 및 하이퍼 파라미터를 조정을 진행하였다.
기본 baseline_code에는 random forest모델을 이용했다. 다른 모델들도 확인해 보기 위해 LightGBM, XGBoost를 테스트해 보니 다음과 같은 결과가 나왔다.
LightGBM : 5841, RandomForest : 5851, XGBoost : 6389 (Valid set 기준)
이 결과를 바탕으로 LightGBM 모델을 사용해서 모델을 돌리기로 했다.
LightGBM 모델로 리더보드에 넣어보니 점수는 18320점이 나왔다.
성능 향상을 위해서 다양한 split 방법을 이용해 보았다.
먼저 기본적으로 hold split 방법으로 데이터를 train과 validation set을 8 : 2로 분리하는 방법을 이용하였다. 그 후로 k fold교차 검증을 이용해서 데이터를 5개로 나누고 그 파라미터들의 평균값을 이용하는 방법을 사용해 보았는데 18808점이 나왔다.
점수가 아예 낮은 것은 아니었지만 LightGBM 단일 모델 점수(18320점)와 비교해 보면 점수가 살짝 낮았기에, 이 방법은 최종적으로 사용하지 않기로 했다. 마지막으로 time split 방법을 이용해 보았는데, 시간을 기준으로 데이터를 단순히 나열한 뒤 train data 80%, valid data 20%로 나누는 방법을 이용해 보았다. 시간을 기준으로 나누어 보니
# 계약년, 계약월로 정렬
sorted_data = concat_select.sort_values(by=['계약년', '계약월'], ascending=[True, True])
# 총 데이터 개수
total_rows = len(sorted_data)
# 80% 기준 인덱스
cutoff_index = int(0.8 * total_rows)
# 최신 20% 데이터의 시작점
latest_20_percent_start = sorted_data.iloc[cutoff_index]
start_year = latest_20_percent_start['계약년']
start_month = latest_20_percent_start['계약월']
print(start_year, start_month)
2019년 6월을 기준으로 80%와 20%로 나뉘었다. 그래서 이걸 이용해서 train data와 valid data를 나누고
# 학습 데이터와 검증 데이터를 조건에 따라 분리
X_train = dt_train.query('(계약년 < 2019) or (계약년 == 2019 and 계약월 < 6)')
X_val = dt_train.query('(계약년 > 2019) or (계약년 == 2019 and 계약월 >= 6)')
# Target과 독립변수 분리
y_train = X_train['target']
y_val = X_val['target']
X_train = X_train.drop(['target'], axis=1)
X_val = X_val.drop(['target'], axis=1)
# 데이터 크기 확인
train_size = len(X_train)
val_size = len(X_val)
total_size = train_size + val_size
# 비율 계산
train_ratio = train_size / total_size * 100
val_ratio = val_size / total_size * 100
# 출력
print(f"Training Data: {train_size} rows ({train_ratio:.2f}%)")
print(f"Validation Data: {val_size} rows ({val_ratio:.2f}%)")
Training Data: 900418 rows (80.48%)
Validation Data: 218404 rows (19.52%)
이를 이용해 모델을 돌려보니 결과가 예상보다 좋지 않았다.
분리 방식에 문제가 있었을 가능성을 고려해서 사이킷런의 timeseriessplit을 이용해 데이터를 다시 분리해 보았다.
from sklearn.model_selection import TimeSeriesSplit
# TimeSeriesSplit 함수를 선언합니다.
kf = TimeSeriesSplit(n_splits=5)
# 데이터를 분할하여 각 분할에서의 인덱스를 출력합니다.
for fold_idx, (train_idx, valid_idx) in enumerate(kf.split(X_train)):
print(f"Fold {fold_idx + 1}")
print(f"Train indices: {train_idx}")
print(f"Validation indices: {valid_idx}")
print("")
# 분할된 데이터를 fold별로 시각화하기 위한 함수를 구성합니다.
# Scikit-learn에서 https://scikit-learn.org/stable/auto_examples/model_selection/plot_cv_indices.html 사용한 코드를 가져와서 사용하겠습니다.
cmap_data = plt.cm.Paired
cmap_cv = plt.cm.coolwarm
def plot_cv_indices(x, y, cv, ax, split_strategy='KFold', group=None, lw=10):
"""Create a sample plot for indices of a cross-validation object."""
for ii, (tr, tt) in enumerate(cv.split(X=x, y=y, groups=group)):
# Fill in indices with the training/test groups
print(f"Fold {ii} :")
print(f" Train : index={tr[:5]}...")
print(f" Valid : index={tt[:5]}...")
indices = np.array([np.nan] * len(x))
indices[tt] = 1
indices[tr] = 0
# Visualize the results
ax.scatter(
range(len(indices)),
[ii + 0.5] * len(indices),
c=indices,
marker="_",
lw=lw,
cmap=cmap_cv,
vmin=-0.2,
vmax=0.2,
)
# Formatting
yticklabels = list(range(5))
ax.set(
yticks=np.arange(len(yticklabels)) + 0.5,
yticklabels=yticklabels,
xlabel="Sample index",
ylabel="CV iteration",
ylim=[len(yticklabels) + 0.2, -0.2],
xlim=[0, len(x)],
)
ax.set_title(split_strategy, fontsize=15)
return ax
# TimeSeriesSplit를 시각화합니다.
fig, ax = plt.subplots()
plot_cv_indices(x=X_train,
y=y_train,
cv=kf,
ax=ax,
split_strategy='Time-Series K-Fold')
위와 같이 분리가 된다. time series k fold는 단순히 시간을 기준으로 80 대 20으로 나누는 것이 아닌, k 번째 fold까지 Train, k+1 번째 fold까지 Test 하는 방식이다. 그러나 이 방법 역시 성적이 좋지 않았기 때문에 결국 그냥 기본 방식인 hold out split 방식을 이용했다.
시계열 기반 방법들이 성적이 좋지 않은 것은 의아한 결과였다. 부동산 가격은 시계열 정보를 포함하고 있고, 시간이 지남에 따라 물가 상승과 함께 전반적인 부동산 가격 상승효과가 있기 때문에 이러한 데이터를 활용하면 더 나은 결과를 얻을 것이라고 예상했기 때문이다. 이 방법이 효과를 내지 못한 이유에 대해서는 고민해 본 결과가 있으며, 자세한 내용은 이후 피드백 부분에서 다루겠다.
성능 향상을 위해서 다양한 머신러닝 알고리즘을 앙상블 하는 방법을 이용해 보았다.
먼저 사이킷런의 votingregressor를 이용하였다. VotingRegressor는 말 그대로 여러 모델의 예측 결과를 투표를 통해 결합하는 방식이다. Hard 방식은 결과 값으로 많이 선택된 값을 사용하는 방식이고, soft 방식은 모델 별로 가중치를 주어서 값을 조정하는 방식인데, 가중치 설정을 안 하면 1:1 비율로 가중치를 준다. 나는 1:1 비율로 가중치를 설정하고, LightGBM 알고리즘과 XGBoost 알고리즘을 VotingRegressor에 적용해 보았다.
# VotingRegressor 생성 및 학습
ensemble_model = VotingRegressor([('lgb', gbm), ('xgb', xgb_model)], n_jobs=-1)
ensemble_model.fit(X_train2, y_train)
그 결과 점수가 17187점까지 떨어졌다.
그리고 마지막으로 optuna를 이용해서 하이퍼파라미터 튜닝을 시도하였다. Optuna는 기계 학습 모델 설계를 위해 개발된 자동 하이퍼파리미터 최적화 framework이다. 이 프레임워크를 사용하면 효율적으로 하이퍼파라미터를 탐색할 수 있다. 먼저 optuna를 다음과 같이 구성하였다.
from optuna.integration import LightGBMPruningCallback
def objective(trial):
param = {
'boosting_type': 'gbdt',
'objective': 'regression',
'metric': 'rmse',
'n_estimators': trial.suggest_categorical('n_estimators', [5000, 10000, 20000]),
'learning_rate': trial.suggest_loguniform('learning_rate', 1e-4, 0.1),
'num_leaves': trial.suggest_int('num_leaves', 20, 300),
'max_depth': trial.suggest_int('max_depth', 3, 15),
'min_child_samples': trial.suggest_int('min_child_samples', 5, 100),
'min_child_weight': trial.suggest_loguniform('min_child_weight', 1e-3, 10.0),
'subsample': trial.suggest_uniform('subsample', 0.5, 1.0),
'colsample_bytree': trial.suggest_uniform('colsample_bytree', 0.5, 1.0),
'lambda_l1': trial.suggest_loguniform('lambda_l1', 1e-3, 10.0),
'lambda_l2': trial.suggest_loguniform('lambda_l2', 1e-3, 10.0),
}
# LightGBM 모델 생성
gbm = lgb.LGBMRegressor(**param)
# Pruning Callback 추가
gbm.fit(
X_train2, y_train,
eval_set=[(X_train2, y_train), (X_val2, y_val)],
eval_metric='rmse',
early_stopping_rounds=50,
callbacks=[
LightGBMPruningCallback(trial, "rmse"), # Pruning을 위한 콜백
lgb.early_stopping(stopping_rounds=50),
lgb.log_evaluation(period=10, show_stdv=True)
]
)
# 검증 데이터에 대한 RMSE 계산
preds = gbm.predict(X_val2, num_iteration=gbm.best_iteration_)
rmse = mean_squared_error(y_val, preds, squared=False)
return rmse
이후 trail을 100으로 설정하고, 100번의 시행을 통해 제일 결과가 잘 나온 파라미터를 추출하는 방식을 이용했다.
# Optuna Study 생성
study = optuna.create_study(direction='minimize') # RMSE를 최소화하는 방향
study.optimize(objective, n_trials=100) # 100번의 실험 수행
코드는 정상적으로 작동하였지만 실행 시간이 너무 오래 걸렸다. Optuna의 존재를 너무 늦게 알았기 때문에 시간이 너무나 부족했다. 17시간 동안 코드를 실행했는데 아직 결과가 도출이 안된 것을 보고, Optuna를 이용해 하이퍼파라미터를 튜닝하는 작업을 포기했다. trail을 100으로 설정한 부분이 이렇게 오랜 시간이 걸릴 줄은 몰랐다. 늦게라도 trail 수를 줄여서 다시 시도해 보거나 GPU를 이용하는 방법을 이용했으면 Optuna를 결과에 사용을 할 수 있었을 텐데 정말 아쉬웠다.
그렇게 해서 결국 최종 버전은 LightGBM만 이용한 버전과 LightGBM과 XGBoost를 앙상블한 버전 2개를 최종으로 제출했다. 그 결과 3등이라는 성과를 거두었다.
4. 피드백 및 개선
멘토님 피드백
그다음으로는 멘토님의 피드백을 받았다. 멘토님께서 다양한 피드백을 해주셨는데, 그중 가장 기억에 남는 부분은 VotingRegressor에 관한 부분이었다.
멘토님께서 VotingRegressor를 이용할 때, 왜 LightGBM과 XGBoost를 선택했냐고 물으셨다. 사실 이 2개의 모델을 사용한 이유는 딱히 없었고 그냥 구글링을 해보니 이 2가지 모델을 앙상블한 사례를 발견해서 이를 그대로 시도해 본 것이었다. 이에 대해 멘토님께서 LightGBM과 XGBoost는 둘 다 부스팅 계열에 속하므로 다양성을 확보하기 위해 random forest와 같은 다른 계열의 모델을 함께 사용하는 것이 앙상블 효과가 올라갈 것이라고 조언해 주셨다.
또한 전처리 과정에서 지하철 역을 이용한 좌표에 관련해서 조언해 주셨다. 지금 이용한 지하철과 아파트의 거리는 말 그대로 좌표 X와 좌표 Y를 직접 연결한 직선거리이다. 그러나 현실에서는 당연히 지하철역까지 직선으로 이어져있지 않고, 도로로 이어져있다. 경우에 따라서는 직선거리로는 지하철역이 더 가까워도 실제 거리는 더 멀 수도 있다. 이러한 경우를 고려한다면 더욱 정확한 결과를 얻을 수 있을 것이라고 조언해 주셨다.
적용하지 못한 방법들
지금부터는 이번 대회에서 적용하지 못하거나 적용하지 않은 방법들과 스스로 생각해 본 더 나은 결과물을 얻을 수 있는 개선 방안들을 정리해 보겠다.
일단 먼저 제일 아쉬웠던 부분은 optuna를 적용하지 못한 것이었다. 결과를 빨리 정리해서 Optuna를 쓸 시간이 더 많았거나, Optuna의 trail 수를 줄이거나, 혹은 GPU를 사용하는 방법이라도 실행했으면(서버 환경에서 작업은 처음이라 GPU 사용 방법을 몰랐다) 더 좋은 결과를 낼 수 있었을 거 같아서 아쉽다. 또, 앙상블 기법을 다양한 데이터와 모델에 사용하지 못했다. 마지막으로 전처리한 데이터를 VotingRegressor에 넣었어야 했는데 VotingRegressor가 자꾸 이런 오류를 출력했다.
ValueError: Must have at least 1 validation dataset for early stopping
분명 validation set를 코드에 추가했음에도 불구하고 오류가 발생했으며, 이전에 사용하던 데이터에서는 정상적으로 작동하던 코드가 전처리한 데이터에서는 작동을 하지 않았다. 그래서 결국 제출본에는
1. 최종 전처리 데이터와 기본 LightGBM 모델 조합
2. 중간 전처리 데이터와 앙상블 모델 조합
이런 두 가지 버전을 제출하였다.
하지만, 최종 전처리 데이터와 앙상블 모델을 조합했다면 더 나은 성적을 얻을 수 있었을 것이라고 생각한다. 물론, 이러한 결과는 내 개발 실력 부족과 시간 부족에서 비롯된 것이지만, 그럼에도 불구하고 아쉬움이 남는다.
시간을 기준으로 데이터를 8 : 2로 분리하는 방법도 생각해 봤지만 효과가 안 나와서 폐기했었다. 그러나 지금 생각해 보아도 이 방법은 충분히 효과를 낼 수 있는 방법이라고 생각한다. 효과가 나오지 않은 이유는 아마 2021년 코로나를 기준으로 시장에 현금이 많아져서 부동산 가격 추세가 많이 바뀌었기 때문인 거 같다. train data에 코로나 이후 데이터를 아예 제외시키면 모델이 이러한 추세를 고려하지 못할 가능성이 크다. 이를 개선해 볼 수 있는 점은 먼저 8 : 2 가 아닌 9 : 1로 분리를 시도해 볼 수 있고, 혹은 아예 랜덤으로 분리가 아닌 validation set에 최근 부동산 거래의 비율을 높이는, 그런 방식을 택했다면 성적이 더 좋게 나왔을 거 같다. 이러한 개선 아이디어들은 대회가 끝난 후에야 떠올랐다는 점에서 아쉬움이 크다. 만약 이러한 방법을 시도했더라면 더 나은 성적을 기대할 수 있었을 것이다.
또한, 이 부분 역시 시간 부족으로 인해 제대로 진행하지 못했던 점이 아쉽다. 칼럼 삭제 작업에 더 많은 시간을 할애했어야 했다고 생각한다.
당시에는 단순히 결측치 비율이 80%를 넘는 칼럼을 삭제하는 방식만을 사용했다. 그러나 상관관계 분석(correlation)을 통해 변수들 간의 관계를 더 정교하게 분석하고, 다양한 조합을 시도해 보았다면 더 나은 결과를 얻을 수 있었을 것이다. 특히, 중요도가 낮은 변수들을 적절히 제거하거나 변수 조합을 최적화했다면 성능 개선에 큰 기여를 했을 가능성이 높다.
더 나은 결과물을 얻을 수 있는 개선 방안
마지막으로 이렇게 했더라면 성적이 더 좋게 나왔을 것 같지만, 당시 상황을 돌아보면 이 방법들을 실행하기는 쉽지 않았을 것 같다. 결과론적으로 아래 설명할 방법들을 채택하는 것이 맞았지만, 당시에는 이 방법들이 더 나은 결과를 보장하지 않았기 때문에 제외했다. 그러나 지금 다시 생각해 보면 이 방법들을 적용했다면 더 좋은 결과를 얻을 수 있었을 것이다.
먼저 k fold 교차 검증을 적용했더라면 더 좋은 결과가 나왔을 것이다. 앞서 서술했듯이 k fold를 적용했을 때 k fold를 적용하지 않았을 때와 비교해서 점수가 살짝 떨어졌기 때문에 k fold를 적용하지 않았다. 그러나 private leaderboard를 보면 k fold를 적용한 경우가 결과가 훨씬 좋게 나왔다.
당시에는 public leaderboard에서 결과가 조금이라도 나쁜 모델을 private leaderboard에 제출하는 것이 부담스러웠다. public leaderboard에서 좋은 점수를 받은 모델이 정답에 더 가깝다고 판단했기 때문이다. 그러나 지금 돌아보면, 결국 k-fold를 적용한 모델을 제출했어야 했다는 아쉬움이 남는다.
또한, 재건축 여부를 반영한 파생변수에 대해서도 아쉬움이 있다. 당시에는 이 변수를 추가했을 때 public leaderboard에서 점수가 오히려 하락하여 변수를 제거했었다. 하지만 private leaderboard 결과를 보면, 재건축 여부는 부동산 가격 예측에 중요한 영향을 미쳤던 것으로 보인다. 만약 이 파생변수를 적용한 채로 제출했다면 더 좋은 결과를 얻었을지도 모른다. 하지만 당시에는 public leaderboard에서 점수가 좋지 않았기 때문에 이를 제외할 수밖에 없었다.
5. 느낀 점
우선, 협업은 이번 프로젝트에서 가장 힘들었던 부분 중 하나였다. 다양한 분야와 나이대의 사람들과 함께 협업하는 경험은 처음이었기 때문에 과정 자체가 쉽지 않았다. 하지만 그만큼 많은 것을 배우고 성장할 수 있었던 소중한 기회이기도 했다.
또한, 캐글 대회의 스트레스를 직접 경험할 수 있었다. 내가 생각하기에 분명 더 좋은 결과를 가져올 것 같은 방법이 원하는 결과로 이어지지 않을 때, 그리고 이러한 방법을 최종 제출물에서 제외했는데 결과적으로 그것이 더 나은 방법이었다는 사실을 알았을 때 느끼는 스트레스는 상당했다.
게다가 코드 에러를 해결하지 못한 부분, optuna 적용 실패, 개선 방안이 대회 종료 후에 떠오른 점 등은 스스로에게 화가 나고 실망스러웠던 지점이었다.
특히, 능동적인 사고 부족은 이번 대회에서 가장 크게 느낀 아쉬움이었다. 이를 가장 강하게 느꼈던 순간은 멘토님께서 VotingRegressor에 대해 "왜 LightGBM과 XGBoost를 선택했냐"라고 질문하셨을 때였다.
당시 나는 명확한 이유 없이 단순히 구글링에서 나온 결과를 따라 했다는 점이 부끄러웠다. 단순히 두 모델을 사용하는 것에 그치지 않고, 모델을 3개, 4개, 혹은 더 다양하게 조합하거나, 두 모델이 비슷한 계열이라는 문제를 스스로 고민했어야 했다. 하지만 이런 부분들을 전혀 고려하지 않았다는 점에서 크게 반성하게 되었다.
돌아보면 대회 진행 중 피곤함과 지침으로 인해 "빨리 끝났으면 좋겠다"는 생각으로 대충 진행한 부분도 있었다. 이러한 태도가 스스로에게 가장 큰 실망이었다. 앞으로는 더 능동적으로 사고하고, "왜 이 모델을 선택했는가?", "다른 모델은 사용하지 않는 이유가 무엇인가?", "더 나은 방법은 없는가?"와 같은 질문을 끊임없이 던지는 개발자가 되어야겠다고 다짐했다.
이번 경진대회는 힘든 과정이었지만, 그만큼 많은 것을 배우고 얻을 수 있었던 값진 경험이었다. 다음에는 더 능동적이고 계획적인 태도로 도전하겠다는 다짐을 남긴다.
https://github.com/UpstageAILab6/upstage-ml-regression-ml-6