
해커톤에서는 부동산 데이터와 AI 기술을 활용하여 허위매물을 분류하는 AI 알고리즘을 개발하는 것을 목표로 합니다.
해커톤에서 제공하는 데이터는 아래와 같습니다.
| ID | 매물확인방식 | 보증금 | 월세 | 전용면적 | 해당층 | ... | 중개사무소 | 게재일 | 허위매물여부 |
|---|---|---|---|---|---|---|---|---|---|
| 1 | 현장확인 | 402500000.0 | 470000 | NaN | NaN | ... | t93Nt6I2I0 | 2024-10-09 | 0 |
| 2 | 현장확인 | 170500000.0 | 200000 | NaN | 3 | ... | q39iV5J4E6 | 2024-12-26 | 0 |
| 3 | 전화확인 | 114000000.0 | 380000 | NaN | 2 | ... | b03oE4G3F6 | 2024-11-28 | 0 |
| 4 | 현장확인 | 163500000.0 | 30000 | 36.3 | 3 | ... | G52Iz8V2B9 | 2024-11-26 | 0 |
| 5 | 현장확인 | 346000000.0 | 530000 | NaN | 3 | ... | N45gM0M7R0 | 2024-06-25 | 1 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
데이터를 확인해보니, 부동산 매물별로 특징이 다르며, 허위매물여부를 확인할 수 있습니다.
어떤 특징을 가진 부동산 매물을 허위매물로 분류할 수 있을까요?

AI 모델은 대량의 데이터를 입력받아 패턴을 학습하여 주어진 문제를 해결할 수 있도록 합니다.
하지만, 데이터를 그냥 입력하면 알아서 해결해 주는것이 아니라, 문제 해결에 중요한 데이터들을 사람이 파악하여 컴퓨터가 잘 이해할 수 있도록 제공하는 것이 중요합니다.
따라서, 앞으로 다음의 순서로 허위매물을 분류하는 모델을 개발해보겠습니다.
먼저, 데이터를 요약해보면 다음과 같습니다.
import pandas as pd
df = pd.read_csv('train.csv')
# 데이터 요약 정보 확인
df.info()
# 전체 데이터 개수 확인
len(df)
# 중복 데이터 개수 확인
df.duplicated().sum()
# 결측 데이터 개수 확인
df.isna().sum()
# 불필요 데이터 개수 확인
for column in df.columns:
# 칼럼 별 unique 종류 개수 확인
print(column, len(df[column].unique()))
# 숫자형 칼럼 개수 확인
list_numerical_columns = list(df.select_dtypes(include=['int', 'float']).columns)
print('numerical', len(list_numerical_columns), list_numerical_columns)
# 범주형 칼럼 개수 확인
list_categorical_columns = list(df.select_dtypes(include=['object']).columns)
print('categorical', len(list_categorical_columns), list_categorical_columns)
# 타겟 데이터 개수 확인
target_column = '허위매물여부'
df[target_column].value_counts()
다음으로, 요약한 데이터들을 시각화를 통해 자세히 분석해보겠습니다.

전체 매물 중 허위매물 개수가 298개로 불균형함을 확인할 수 있습니다.
범주형 칼럼은 6개가 있었습니다.
각 칼럼별 숫자 분포와 비율 분포를 시각화해서 확인해보겠습니다.

그래프를 통해 아래와 같이 확인해볼 수 있었습니다.
중개사무소와 게재일은 종류가 많아서 다음과 같이 처리 후 시각화를 진행하겠습니다.

처리 후 시각화 해보니 다음과 같이 확인해 볼 수 있었습니다.
숫자형 칼럼은 9개가 있었습니다.
각 칼럼별 숫자 분포와 이상치값 존재 여부를 시각화해서 확인해보겠습니다.

그래프를 통해 아래와 같이 확인해볼 수 있었습니다.
총주차대수와 관리비는 이상치로 인해 분포 확인이 힘들기 때문에 로그스케일로 변환 후 확인해주었습니다.

다음으로, 숫자형 데이터들이 정규성을 띄는지 Q-Q plot을 통해 확인해봤습니다.
Q-Q plot은 대각선 위에 데이터가 위치해있으면 정규분포를 띈다고 이해할 수 있습니다.

그래프를 확인해보니, 각 숫자형 데이터들은 모두 정규분포를 띄지 않음을 확인할 수 있습니다.
다음으로, 숫자형 데이터들의 상관관계를 시각화해보았습니다.
앞서 확인한 것 처럼 숫자형 데이터들은 모두 정규성을 만족하지 않으므로, 스피어만 상관계수를 통해 확인해보았습니다.

그래프를 통해 각 숫자형 칼럼간의 어느정도 상관관계를 확인할 수 있었습니다.
총층이 높을수록, 해당층이 높거나 전용면적이 클수로 방수가 넓다는 것으로 이해할 수 있습니다.
마지막으로, 숫자형 데이터들의 다중공선성을 확인해보았습니다.

다중공선성은 하나의 독립변수가 다른 여러 개의 독립변수들로 잘 예측되는 경우로, 독립 변수(특징)들 간의 상관관계가 높아 모델 성능에 영향을 줄 수 있는 현상을 의미합니다.
다중공선성과 상관관계의 차이는 아래와 같습니다
각 피쳐별 다중공선성을 확인해보면 다음과 같습니다.

VIF(Variance Inflation Factor)는 각 변수의 다중공선성을 수치화한 값입니다.
| Feature | VIF | 해석 |
|---|---|---|
| 보증금 | 2.64 | 다중공선성 문제 없음 ✅ |
| 월세 | 4.15 | 다중공선성 문제 없음 ✅ |
| 전용면적 | 14.55 | 다중공선성이 높음 ❌ |
| 해당층 | 5.49 | 다중공선성이 의심됨 ⚠️ |
| 총층 | 7.92 | 다중공선성이 의심됨 ⚠️ |
| 방수 | 11.30 | 다중공선성이 높음 ❌ |
| 욕실수 | 21.15 | 다중공선성이 매우 높음 ❌ |
| 총주차대수 | 1.77 | 다중공선성 문제 없음 ✅ |
| 관리비 | 2.04 | 다중공선성 문제 없음 ✅ |
하지만, 다중공선성이 높다고 무조건 제거하는 것은 아닙니다.
결측 데이터 개수는 다음과 같았습니다.
결측 데이터의 매물개수를 시각화해보았습니다.

그래프를 통해 다음과 같이 확인할 수 있었습니다.
데이터 분석을 통해 다음과 같이 확인할 수 있었습니다.
따라서, 결측치를 회귀모델을 사용하여 예측 후 대치하도록 했습니다.
결측치 대치 전략은 다음과 같습니다.
아래는 회귀모델을 사용하여 각 결측 데이터를 순회하며 결측치를 대치하는 코드입니다.
import time
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from sklearn.ensemble import RandomForestRegressor
from sklearn.impute import SimpleImputer
from sklearn.model_selection import cross_val_predict
from sklearn.metrics import mean_squared_error, mean_absolute_error
# 시작 시간 기록
start_time = time.time()
# 결측값이 있는 칼럼 리스트
missing_columns = ['전용면적', '총주차대수', '해당층', '욕실수', '총층', '방수']
# 평가 지표 저장용
results = {}
# 기본 RandomForestRegressor 모델 정의
rf = RandomForestRegressor(
n_estimators=100,
criterion='squared_error',
max_depth=None,
random_state=42
)
# 각 칼럼에 대해 결측값 대체
for column in missing_columns:
print(f"ㅡㅡㅡㅡ{column} 결측치 대치중ㅡㅡㅡㅡ")
print(df.isna().sum())
# 결측값을 가진 데이터와 없는 데이터 분리
X = df[list_numerical_columns].drop(columns=[column])
y = df[column]
# 대치할 칼럼 외 칼럼들의 결측값을 평균으로 대체
fill_columns = missing_columns.copy()
fill_columns.remove(column)
for fill_column in fill_columns:
X[fill_column] = SimpleImputer(strategy='mean').fit_transform(X[[fill_column]])
# 결측값이 없는 데이터만 학습에 사용
X_train = X[~y.isna()]
y_train = y[~y.isna()]
# 결측값이 있는 데이터만 예측에 사용
X_test = X[y.isna()]
# 모델 학습
rf.fit(X_train, y_train)
# 교차 검증 예측 값 얻기 (교차 검증은 X_train에 대해서만 예측)
y_pred = cross_val_predict(rf, X_train, y_train, cv=3)
# RMSE와 MAE 계산
rmse = np.sqrt(mean_squared_error(y_train, y_pred))
mae = mean_absolute_error(y_train, y_pred)
# 결과 저장
results[column] = {
'rmse': rmse,
'mae': mae,
'model': rf
}
# 결측값 예측 (여기서 예측값을 채워야 함)
y_pred_test = rf.predict(X_test)
# 결측값 채우기
df.loc[df[column].isnull(), column] = y_pred_test
# 결과 출력
print(f"ㅡㅡㅡㅡ결측치 대치 완료ㅡㅡㅡㅡ")
print(df.isna().sum())
# 끝 시간 기록
end_time = time.time()
# 학습 시간 계산
training_time = end_time - start_time
print(f"모델 학습 시간: {training_time:.2f} 초")
# 모델별 결과 요약을 DataFrame으로 변환
result_df_1 = pd.DataFrame.from_dict(results, orient='index')
result_df_1 = result_df_1[['rmse', 'mae']]
앞서 이야기한 회귀 모델 평가 지표는 다음의 값을 사용하였습니다.

두 값은 모델의 예측값과 실제값 간의 차이를 측정한 값이며 낮을수록 예측을 잘했다고 이해할 수 있습니다.
각 숫자형 데이터 결측치 대치 후 통계값과 지표값을 확인하면 다음과 같습니다.
| 항목 | 최소값 | 평균값 | 중앙값 | 표준편차 | 최대값 | RMSE | MAE |
|---|---|---|---|---|---|---|---|
| 전용면적 | 17.5 | 27.661016 | 26.575 | 7.598453 | 49.97 | 7.008410 | 5.567027 |
| 총주차대수 | 1.0 | 16.957055 | 7.440 | 26.529787 | 590.00 | 23.110822 | 11.677323 |
| 해당층 | 1.0 | 4.909233 | 4.000 | 3.463096 | 21.00 | 2.569291 | 1.648115 |
| 욕실수 | 1.0 | 1.034258 | 1.000 | 0.181928 | 2.00 | 0.148497 | 0.041623 |
| 총층 | 2.0 | 8.050893 | 6.000 | 4.908684 | 21.00 | 3.192540 | 2.123030 |
| 방수 | 1.0 | 1.376032 | 1.000 | 0.484096 | 2.00 | 0.273923 | 0.157989 |
데이터가 정규분포를 따르지는 않지만, 각 칼럼의 표준편차보다 오차값인 MRSE, MAE 값이 낮아서 결측치 대측이 어느정도 되었다고 판단하였습니다.
추가로, 결측치 대치 전과 후의 데이터 분포를 시각화하여 결측치 대치에 문제가 없는지 확인해보았습니다.

그래프를 통해 다음과 같이 확인할 수 있었습니다.
피처 엔지니어링은 데이터를 분석해서 중요한 패턴을 찾아, 모델이 더 잘 이해할 수 있도록 가공하는 과정입니다.
데이터를 그냥 모델에 넣으면 모델이 중요한 패턴을 잘 찾기 어렵기 때문에, 데이터 분석을 통해 중요한 패턴을 발견하고, 모델이 잘 이해하도록 가공해야 합니다.
먼저, 어떤 피쳐가 중요한지 간단히 알아보기 위해 아래와 같이 기본 모델을 학습시켜보았습니
model = RandomForestClassifier(
n_estimators=100,
criterion='gini',
max_depth=None,
random_state=42
)
model.fit(x, y)
학습된 모델의 피쳐 중요도를 확인하기 위해 아래의 방법을 사용했습니다.
Permutation Importace의 개념은 아래와 같습니다.

결과값을 확인해보니, 게재일과 중개사무소 칼럼의 값을 랜덤하게 변할때 모델 성능이 크게 하락했음을 알 수 있었고, 모델에 영향도가 높다고 이해할 수 있습니다.
SHAP Value의 개념은 아래와 같습니다.

결과값을 확인해보니, 위와 같이 게재일과 중개사무소가 예측 결과에 기여도가 높았음을 알 수 있었고, 인코딩된 값의 크기에 따라 양/음의 기여도를 미쳤음을 이해할 수 있습니다.
피쳐 중요도를 확인해보니 중개사무소와 게재일 피쳐가 모델에 큰 영향을 주었다는 것을 알았습니다.
도메인 관점에서도 생각해보면 허위매물은 각 중개사무소가 등록하는 것이니 어쩌면 당연한 것 같습니다.
중개사무소와 게재일 피쳐에 대해 이전에 데이터 분석을 통해 다음의 주요 인사이트를 확인할 수 있었습니다.

위의 피쳐들의 인사이트들을 모델에게 다음과 같이 알려주고싶었습니다.
따라서 아래와 같이 다양한 방법으로 모델에게 어떻게 알려줘야할지 고민해보았습니다.
위와같은 다양한 가설을 적용해보면서 가장 점수가 높았던 방법은 아래와 같았습니다.
중개사무소별 숫자형 데이터의 평균값을 시각화해보면 다음과 같습니다.

각 숫자형 데이터의 평균값이 중개사무소별로 다르게 분포해있음을 확인할 수 있습니다.
따라서, 분포해 있는 각 중개사무소를 KMeans 알고리즘으로 아래와 같이 클러스터링 하고자했습니다.

KMeans 클러스터링은 거리 기반 알고리즘이므로, 아래의 특징이 클러스터링에 영향을 미칠 수있습니다.
따라서, 먼저 중개사무소별 각 숫자형 데이터의 평균값간의 상관계수와 다중공선성 여부를 확인해보았습니다.
먼저, Q-Q Plot 그래프를 확인해보니, 각 숫자형 데이터들은 모두 정규분포를 띄지 않음을 확인할 수 있습니다.

중개사무소별 각 숫자형 데이터의 평균값은 모두 정규성을 만족하지 않으므로, 스피어만 상관계수를 통해 확인해보았습니다.

그래프를 통해 각 숫자형 칼럼간의 어느정도 상관관계를 확인할 수 있었습니다.
다음으로, 다중공선선을 확인해본 결과 다음과 같았습니다.
| Feature | VIF |
|---|---|
| 허위매물_비율 | 1.587806 |
| 총_매물수 | 1.070661 |
| 평균_보증금 | 4.732560 |
| 평균_월세 | 6.920095 |
| 평균_전용면적 | 10.812211 |
| 평균_층수 | 28.068860 |
| 평균_총층 | 37.807254 |
| 평균_총주차대수 | 3.320291 |
| 평균_관리비 | 3.874353 |
상관관계에서 확인해본 것 처럼 아래 값들의 VIF가 높은것을 확인할 수 있었습니다.
따라서, 하나씩 제거해 나가면서 휴리스틱하게 클러스터링 할 피쳐들을 선별하여 다중공선성을 낮추었습니다.
| Feature | VIF |
|---|---|
| 허위매물_비율 | 1.564794 |
| 총_매물수 | 1.047053 |
| 평균_보증금 | 4.268984 |
| 평균_월세 | 4.461320 |
| 평균_층수 | 6.342668 |
| 평균_관리비 | 3.612628 |
다음으로, 선별한 피쳐들의 단위를 맞춰주기 위해 스케일링을 진행해준다음
features = ['허위매물_비율', '총_매물수', '평균_보증금', '평균_월세', '평균_층수', '평균_관리비']
# 데이터 스케일링
scaler = StandardScaler()
scaled_features = scaler.fit_transform(df[features])
최적의 군집 개수를 갖는 군집을 생성하기 위해 엘보우 그래프를 확인해보았습니다.

엘보우 그래프는 위와 같이 각 클러스터 내 각 데이터 포인트가 클러스터의 중심(centroid)에서 얼마나 떨어져 있는지를 나타내는 값의 제곱합으로, 클러스터링이 진행되면서 해당 값은 작아지며, 값이 급격하게 작아질때마다 최적의 클러스터가 형성됨을 이해할 수 있습니다.

엘보우 그래프 확인 결과, 클러스터 개수가 4개 이후 부터 그래프가 완만해 지기 때문에 클러스터를 3개로 지정하여 클러스터링하였습니다.
그 다음, 각 중개사무소가 어떤 클러스터에 속해있는지 확인해기 위해 PCA를 통해 차원을 2차원으로 축소하여 시각화해보았습니다.

그래프 확인 결과, 군집이 어느정도 생성되어 있음을 확인하였습니다.
그 다음, 클러스터 별 중개사무소의 총 개수와 unique 개수를 살펴보았습니다.


그래프 확인 결과, 클러스터 2는 데이터 분석에서 확인했던 특정 중개사무소 (G52lz8V2B9)임을 알 수 있었고, 각 클러스터의 특징이 잘 드러난 것을 확인할 수 있었습니다.
따라서 아래와 같이 데이터에 다음과 같은 피쳐들을 추가해주었습니다.
| ID | ... | 중개사무소 | 중개사무소_cluster | 중개사무소_cluster_fraud_rate | 중개사무소_cluster_size |
|---|---|---|---|---|---|
| ... | ... | t93Nt6I2I0 | 0 | 0.204301 | 1023 |
| ... | ... | q39iV5J4E6 | 1 | 0.141270 | 630 |
| ... | ... | b03oE4G3F6 | 1 | 0.141270 | 630 |
| ... | ... | G52Iz8V2B9 | 2 | 0.000000 | 799 |
| ... | ... | N45gM0M7R0 | 0 | 0.204301 | 1023 |
게재일은 일별 데이터보다 월별 데이터를 통해 트렌드 확인이 용이하다고 판단하였습니다.
따라서 아래와 같이 데이터 변환 후 다음과 같은 피쳐들을 추가해주었습니다.
YYYY-MM-DD -> YYYY-MM 변환| ID | ... | 게재일 | 게재일_fraud_rate | 게재일_size |
|---|---|---|---|---|
| --- | --- | 202410 | 0.008596 | 349 |
| --- | --- | 202412 | 0.000000 | 240 |
| --- | --- | 202411 | 0.003165 | 316 |
아래의 나머지 범주형 피쳐는 특이사항이 없어서, Label Encoding을 진행하였습니다.
le = LabelEncoder()
# Label Encoding 적용 열
label_encode_cols = ['제공플랫폼','방향', '주차가능여부', '매물확인방식']
for col in label_encode_cols:
df[col] = le.fit_transform(df[col].astype(str))
먼저 Permutation Importace의 결과는 아래와 같았습니다.

Shap Value의 결과는 아래와 같았습니다.

모델 엔지니어링을 진행한 항목 및 종류는 아래와 같습니다.
각 항목의 종류별로 48(=4 x 3 x 4)번의 실험을 진행한 결과 최적의 모델은 다음과 같았습니다.
한마디로 기본 RandomForestClassifier 모델의 점수가 가장 높았습니다.
아래는 기본 RandomForestClassifier 모델 학습 및 예측 코드입니다.
# 기본 RandomForestClassifier 모델 학습 코드
df_train = pd.read_csv("train.csv")
Y = df[target_column]
X = df.drop([target_column], axis=1)
# 랜덤 포레스트 모델 설정
model = RandomForestClassifier(
n_estimators=100,
criterion='gini',
max_depth=None,
random_state=42,
)
# 모델 학습
model.fit(X, Y)
# 허위매물 분류 예측
df_test = pd.read_csv("test.csv")
model.predict(df_test)
아래와 같이 허위매물여부를 모르는 부동산 매물 데이터(df_test)를 넣고 predict를 해주면 모델이 학습한 결과를 바탕으로 허위매물여부를 분류하게됩니다.
| ID | 매물확인방식 | 보증금 | 월세 | 전용면적 | 해당층 | ... | 중개사무소 | 게재일 |
|---|---|---|---|---|---|---|---|---|
| 1 | 현장확인 | 402500000.0 | 470000 | 17.5 | 6 | ... | t93Nt6I2I0 | 2024-10-09 |
| 2 | 현장확인 | 170500000.0 | 200000 | 24.4 | 3 | ... | q39iV5J4E6 | 2024-12-26 |
| 3 | 전화확인 | 114000000.0 | 380000 | 52.2 | 2 | ... | b03oE4G3F6 | 2024-11-28 |
| 4 | 현장확인 | 163500000.0 | 30000 | 36.3 | 3 | ... | G52Iz8V2B9 | 2024-11-26 |
| 5 | 현장확인 | 346000000.0 | 530000 | 12.2 | 3 | ... | N45gM0M7R0 | 2024-06-25 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... |
아래의 세가지 방법으로 모델을 평가해보겠습니다.
첫번째로, Confusion Matrix는 아래와 같으며, 개념과 해석 방법은 다음과 같습니다.

학습한 모델의 Confusion Matrix는 다음과 같습니다.

허위매물이 1클래스이기 때문에 전체 데이터 2452개에 대해 다음과 같이 해석이 가능합니다.
두번째로, Classification Report는 분류 모델의 성능을 자세히 평가하는 리포트입니다.
학습한 모델의 Classification Report는 다음과 같습니다.
| Class | Precision | Recall | F1-score | Support |
|---|---|---|---|---|
| 0 | 0.98 | 0.98 | 0.98 | 2154 |
| 1 | 0.84 | 0.82 | 0.83 | 298 |
| Accuracy | 0.96 | 2452 | ||
| Macro Avg | 0.91 | 0.90 | 0.90 | 2452 |
| Weighted Avg | 0.96 | 0.96 | 0.96 | 2452 |
해커톤의 평가 지표인 f1_macro 지표는 f1_score의 양성,음성 값의 평균으로 0.90임을 확인할 수 있습니다.
마지막으로, ROC-AUC는 정확한 양성(TP)과 잘못된 양성(FP)의 관계를 그래프로 표시한 것입니다.
학습한 모델의 ROC-AUC 그래프는 다음과 같습니다.

0.9725 값을 가진것으로 보아 각 클래스 예측이 양 극단에 위치하여 threshold 변경에 따라 TPR 변화가 크지 않음을 알 수 있습니다.
각 단계별 해커톤 제출 점수는 아래와 같았습니다.
느낀점은 다음과 같습니다.