프로젝트 2 수도권 아파트 전세가 예측 대회 프로젝트의 wrap up report를 직접 정리해 이로써 프로젝트를 마무리 하려한다.
프로젝트 기간 : 2024/09/30 ~ 2024/10/24
프로젝트 평가 기준 : 전세 실 거래 가격과 예측 가격의 MAE(Mean Absolute Error)
데이터 : upstage 대회에서 제공(아래 설명 O)
프로젝트 개요
upstage의 수도권 아파트 전세가 예측 모델 대회 참가를 위한 프로젝트.
약 180만 건의 2019/04~2023/12월 수도권 아파트 전세 실거래가 데이터를 사용해, 약 15만 건의 2024/01~2024/06월 전세 가격을 예측해야 한다.
이름 | 역할 |
---|---|
김건율 | 팀장, EDA, 피처 엔지니어링, LGBM 모델링 및 앙상블 |
백우성 | EDA, 피처 엔지니어링, XGBoost 모델링 |
유대선 | 프로젝트 설계, 모델링 자동화, EDA |
이제준 | EDA, RF 모델링 |
황태결 | 공원, 학교 데이터 EDA |
Slack : 팀 간 실시간 커뮤니케이션, [Github 연동] 이슈 공유, 허들에서 실시간 소통 진행
Zoom : 정기적인 회의와 토론을 위해 사용
GitHub : 버전 관리와 코드 협업을 위해 사용. 각 팀원은 기능 단위로 이슈를 생성해 이슈 별 브랜치를 만들어 작업하고, Pull Request를 통해 코드 리뷰 후 병합하는 방식으로 진행
GitHub Projects + Google Calendar : 팀원 간 일정 공유
우선 도메인 지식 스터디를 통해 팀원 모두가 데이터를 이해하고, 각자 주제 별 EDA를 진행한 뒤, 근거 있는 컬럼에 대해 Feature Engineering 진행.
(각자 EDA 한 내용은 github의 EDA-개인별 폴더에 정리)
컬럼 명 | 자료형 | 데이터 셋 | 설명 |
---|---|---|---|
index | int64 | train/test | 인덱스 |
area_m2 | float64 | train/test | 면적 |
contract_year_month | int64 | train/test | 계약년월 |
contract_day | int64 | train/test | 계약일 |
contract_type | int64 | train/test | 계약 유형(0: 신규, 1:갱신, 2:모름) |
floor | int64 | train/test | 층수 |
built_year | int64 | train/test | 건축 연도 |
latitude | float64 | train/test/subway/school/park | 위도 |
longitude | float64 | train/test/subway/school/park | 경도 |
age | int64 | train/test | 건물나이 |
deposit | float64 | train | 전세 실거래가(타겟 변수) |
year_month | int64 | interest | 연월 |
interest_rate | float64 | interest | 이자율(연월에 해당하는 이자율) |
schoolLevel | object | school | 초등학교, 중학교, 고등학교 여부 |
area | float64 | park | 공원 면적 |
df_train["area"] = (df_train["area_m2"] / 3.3).round(1)
df_train['area_price'] = 3.3*df_train['deposit'] / df_train['area']
Floor : 층수 컬럼은 55층 정도가 넘어가면서 평균 가격이 확 뛰는 것으로 확인했다. 저층일 때보다 오히려 초 고층일때 전세가격 차이가 두드러졌다.
age : 건물의 나이가 적다고 해서 무조건 전세가격이 높은 것은 아니다. 우하향 추세를 보이다 30~50년 사이에 전세 가격이 오르는 현상을 확인했다.
latitude-longtitude : 위도, 경도가 같은 raw는 같은 아파트로 판단해 동일한 apt_idx를 부여했다.
contract_type : 계약유형은 알수없음(2)이 많아 one-hot 인코딩 후 신규와 갱신 컬럼만 넣었다.
contract_year_month : 관계된 컬럼은 연, 월, 일을 분해해서 월과 일을 사인, 코사인함수로 주기성을 줘봤지만, 오히려 계약연월일 단일컬럼으로 했을 때 보다 public score가 낮아 계약연월과 계약일은 합쳐서 단일 컬럼으로 사용했다.
area_m2 : 아파트의 크기는 별도의 변환 없이 바로 상관관계를 찍어봐도 0.52의 강한 상관관계를 보였다.
기본 컬럼 : train에 있는 기본 컬럼들을 로그 변환 후 평당가격과 측정한 상관계수는 area_m2를 제외하곤 절대값으로 0.17을 넘는 컬럼이 거의 없었다. 그러나 기본 컬럼들을 제외했을 때 보다 포함했을 때 MAE 결과가 더 잘나와 사용하기로 결정했다.
XGBOOST : 기본 컬럼 제외 VAL-MAE:3944.77092 / 기본 컬럼 포함 VAL-MAE:3888.09750
부동산에선 ‘O세권’이란 단어를 사용한다. 일반적으로 아파트 근접 인프라가 잘 형성되어있으면 가격이 높아진다. 주어진 subway, school, park info 데이터의 위도, 경도를 활용해서 아파트 별 역세권, 학세권, 공세권을 판단할 수 있는 피처를 만들었다.
# 지구의 평균 반경 (킬로미터 단위, Haversine 공식에 필요)
EARTH_RADIUS_KM = 6371.0
# 아파트의 위도와 경도를 라디안으로 변환
temp_df_rad = np.radians(temp_df[["latitude", "longitude"]].values)
# 지하철 역의 위도와 경도를 라디안으로 변환
subway_info_rad = np.radians(subway_info[["latitude", "longitude"]].values)
# BallTree 생성 (Haversine 사용)
tree = BallTree(subway_info_rad, metric="haversine")
# 각 아파트에 대해 가장 가까운 지하철역과의 거리 찾기
distances, indices = tree.query(temp_df_rad, k=1)
# 거리 (라디안)를 미터 단위로 변환
temp_df["nearest_subway_distance"] = (
distances.flatten() * EARTH_RADIUS_KM * 1000
) # meters
# 가장 가까운 지하철역의 subway_idx 추출
temp_df["nearest_subway_idx"] = (
subway_info["subway_idx"].iloc[indices.flatten()].values
)
상관관계를 확인해보기 위해 분포가 기울어진 평당 가격은 로그변환을 취했다. 가장 가까운 지하철 피처와 전세가격, 평당 전세가격과의 상관관계는 아래와 같다. 지하철역과 아파트가 가까울수록(음의 상관관계) 평당 전세가격이 상승하는 관계다.
거리별로 카테고리화 시켜서 평균 전세가격과 평균 평당가격을 살펴봐도 가까울수록 비싸다.
공원의 경우도 대체적으로 지하철과 비슷한 경향을 보이고, 학교도 일정 거리까진 가까울수록 평당 가격이 비싼 관계를 보인다.
상관계수만 볼 때는 지하철과의 거리말고는 수치가 적어, 아래 4-4의 infra_count에서 새 피처를 추가로 만들어 사용했다.
feature | nearest_school_distance | nearest_park_distance | nearest_subway_distance |
---|---|---|---|
corr | -0.059121 | -0.108378 | -0.410597 |
그래프 상으로는 금리와 전세 가격 사이에 반비례 관계가 존재한다고 추정할 수 있다. 하지만 실제 corr 값으로 확인해본 바, 전체 train dataset으로는 금리와 실거래가 사이의 관계가 있다고 보기 힘들다.
평균 전세가 등락률과 금리와의 관계는 0.47로 평균 실거래가와 금리와의 지수보다는 상승한다. 또한 deposit의 중간 값을 통한 corr 값은 아래와 같다.
corr | monthly_transaction | interest_rate | avg_deposit | deposit_diff |
---|---|---|---|---|
monthly_transaction | 1.000000 | 0.469879 | 0.455964 | -0.219402 |
interest_rate | 0.469879 | 1.000000 | 0.130927 | -0.043196 |
avg_deposit | 0.455964 | 0.130927 | 1.000000 | 0.129704 |
deposit_diff | -0.219402 | -0.043196 | 0.129704 | 1.000000 |
시계열 데이터로 활용할 수 있는 데이터라 SARIMAX에 interest_rate를 외생 변수로 넣어서 평균 전세 가격 추이를 추정해 피처로 활용했지만, public score 상에서 결과가 좋지 않아 폐기했다.
하지만 특정 모델(XGBoost)에서 이자와 관련된 피처(interest_rate, diff_interest_rate)를 오히려 제외할 때 public score(MAE)가 600이상 감소했고, val-mae와 test-mae간의 격차도 완화되었다.
결론적으로, 이자 관련 피처가 결과를 왜곡한다고 판단하여 기본 interest_rate만 사용하거나 LGBM같은 특정 모델에서는 아예 피처에서 제외했다.
일반적으로 부동산 가격을 볼 때 가장 많이 보는 지표는 ‘최근 거래가’이다. 그래서 apt_idx와 area_m2가 같은 가장 최근 raw의 deposit을 찾아 최근 거래가 피처를 만들었다.
같은 apt_idx를 가진 raw끼리 groupby해서 평균 전세가격을 구하고, 서로 비교해 랭킹을 매겨놓은 피처.
외부 행정동 데이터를 사용할 수 없기 때문에 주어진 위도, 경도를 기준으로 일정 크기의 격자(grid)로 나누어 행정동을 대체해서 근처 아파트들 끼리 묶어 활용했다. 이를 통해 test에 있는 아파트들이 어떤 구역에 속하는 지 파악이 가능해진다.
4-2의 infra distance 피처만으로는 부족해 새롭게 생성했다. 각 grid에 속하는 subway, park, school의 수를 count해서 피처로 활용했다. 로그 변환된 평당 전세 가격과 상관계수는 0.493523(지하철), 0.226171(학교), 0.213145(공원)으로 나왔다.
초기 모델 : XGBoost & LightGBM
XGBoost는 레벨 단위로 트리를 생성하고, LightGBM은 리프 단위로 트리를 생성하는 트리기반의 의사결정 알고리즘 모델이다. 두 모델 다 그라디언트 부스팅 기반으로, 대규모 데이터 셋에서 빠른 학습 속도와 효율적인 메모리 활용으로 사용하기 편하다. 그리고 비선형적이고 복잡한 관계도 잘 파악하며, Feature Importance를 제공해 영향력이 큰 피처를 찾기 쉬워서 사용을 결정했다.
평가 지표 : MAE
대회 측정 지표로 활용하는 MAE(Mean Absolute Error, 평균 절대 오차)를 동일하게 사용했다.
Validation 전략 : K-fold CV (k=5)
부동산 데이터의 경우 지역 별 특성이 중요해 누락되는 데이터가 있으면 안된다고 생각했다. 모든 데이터가 훈련과 검증을 참여할 수 있는 k-fold CV 방식을 사용했다. k값은 여러 값을 넣어보고 계산 효율과 성능 평가에 있어 적당한 값이 5라고 판단했다.
하이퍼 파라미터 조정
Optuna를 통해 베이지안 방식으로 하이퍼파라미터를 찾아 해당 값을 사용해봤으나 train과 eval MAE 격차가 700이상 벌어졌고, public score MAE가 더 크게 나와 과적합 이슈로 인해 포기했다. 그래서 랜덤 서치 방식으로 하이퍼파라미터를 조정했다.
추가적으로 시도해본 모델
Stacking Ensemble
XGBoost, LightGBM, RandomForest, Gradient Boosting, ElasticNet Regressor 모델을 기본 모델로 선정해서 독립적으로 학습시켜 validation(random_state=42, test_size=0.2)을 만든다. Linear Regression(or LGBM)를 메타 모델로 선정해 기본모델에서 만들어진 validation을 학습시켜 최종 결과를 stacking 방식으로 도출한다.
하지만 학습시간이 오래 걸리고, 단순 평균이나 단독 모델보다 public score에서 우위를 찾을 수 없어 프로젝트에서 배제했다. (Stacking MAE : 3789.6573 vs LGBM MAE :3730.6235)
Weighted Average Ensemble
최종적으로 살아남은 XGBoost와 LightGBM 모델의 예측 값들을 가중 평균해 제출을 시도했다. public score가 높은 쪽에 가중치를 더 줘서 평균을 내보니 단독 모델보다 MAE가 더 낮게 나와 스태킹 방식이 아닌 가중 평균 앙상블 방식을 계속 사용했다.
public score : 3506.4542 / private score : 4307.9871
V9 데이터셋
columns = ['contract_date_numeric', 'area_m2', 'floor', 'built_year', 'latitude',
'longitude', 'age', 'contract_0', 'contract_1', 'deposit', 'apt_idx',
'area', 'grid_deposit', 'apt_deposit_rank', 'apt_area_deposit_rank',
'recent_deposit', 'nearest_park_distance', 'nearest_park_idx',
'park_area', 'nearest_school_distance', 'nearest_school_idx',
'nearest_subway_distance', 'nearest_subway_idx', 'park_count',
'school_count', 'subway_count']
{
"objective": "regression",
"metric": "mae",
"boosting_type": "gbdt",
"num_leaves": 200,
"learning_rate": 0.01,
"feature_fraction": 0.8,
"bagging_fraction": 0.7,
"bagging_freq": 1,
"num_boost_round": 15000,
"early_stopping_rounds": 100,
"gpu_platform_id": 0,
"gpu_device_id": 0,
}
{
"objective": "reg:absoluteerror",
"eval_metric": "mae",
"max_depth": 10,
"subsample": 0.8,
"colsample_bytree": 0.8,
"learning_rate": 0.01,
"num_boost_round": 20000,
"early_stopping_rounds": 100,
"verbose_eval": 100,
"device": "cuda"
}
{
"objective": "regression",
"metric": "mae",
"boosting_type": "gbdt",
"num_leaves": 64,
"learning_rate": 0.05,
"subsample": 0.8,
"colsample_bytree": 0.8,
"feature_fraction": 0.8,
"lambda_l2": 0.1,
"bagging_fraction": 0.7,
"bagging_freq": 1,
"num_boost_round": 50000,
"early_stopping_rounds": 1000,
"n_jobs": -1
}
{
"objective": "reg:absoluteerror",
"eval_metric": "mae",
"max_depth": 6,
"subsample": 0.8,
"colsample_bytree": 0.8,
"learning_rate": 0.05,
"min_child_weight": 10,
"reg_lambda": 0.1,
"reg_alpha": 0,
"num_boost_round": 50000,
"early_stopping_rounds": 1000,
"verbose_eval": 1000,
"n_jobs": -1,
"gamma": 0,
}