첫번째 프로젝트인 서울시 부동산 프로젝트의 아쉬운점과 당시 배우고 있던 ML관련학습을 위해 진행한 프로젝트다.
원하는 정보가 있는지 서치하고, 적용하고, 없다면 만들어서 넣는 등의 활동을 하면서 데이터에 대한 이해와 피쳐선정을 어떻게 해야하는지 배울 수 있었다. 다만 '무엇을 만들어서 어떻게 사용하겠다'라는게 존재하지 않지 않았나라는 생각을 한다. 어떤 기술을 쓸때는 의도가 있어야 하는데 배웠다고 쓰고, 신기하다고 쓰고, 호기심에 쓰는 등 기술 선택에 있어서 리소스 낭비가 있었다.
또 머리속에 있는것을 쏟아내는것에 집중하여 정리가 안된것도 흠이다. 고생을 더블로 하는 기분...
그렇지만 스스로 찾아서 배워서 사용해보고, 어떤게 필요할까 고민하면서 가장 많이 성장한 프로젝트라고 생각한다.
진행기간 : 09/17~10/12(4주)/1인
1. 데이터셋
서울시 부동산의 2010~2024까지의 데이터
행정동-법정동 매핑 데이터
2010~2024 주담대 금리 데이터
지역별 미분양 통계
2.시기 구분

2008년은 다들 기억할지 모르겠지만 리먼브라더스의 파산 이후 전세계적인 금융공황이 있었던 시기이다.
한국은 이것을 극복하는 시기가 2010년으로 금융공황 이후 부동산 시장 역시 회복하던 시기다.
이를 알수 있는 지표로는 2010년 3/4분기에 금리인상을 볼 수 있는데 데이터를 확인한 결과 해당 시기부터 부동산 시장이 성장하고 있음을 알 수 있었다.
그렇기에 2010~ 2014는 회복기, 2015~2021는 성장기/ 2022~는 조정기라고 정의하고 진행하였다.
3. 데이터 전처리
- 전처리 및 행정동 매핑
- 컬럼명 표준화, 중복 및 핵심 칼럼의 결측치 제거, 최소 거래 필터링
- 자치구코드와 법정동 코드를 결합해서 10자리 법정동코드 생성
- 10자리 코드를 기준으로 법정동-행정동 매핑, 행정동명 부여
- 준공년-계약년 기준으로 5>신축
- 신구축 구분과 건물 유형을 조합해서 type1(오피스텔, 연립다가구, 아파트, 신축단독다가구), type2(구축 단독 다가구)
- 상대적 층수로 지역별/유형별 분위수를 통해서 상대적 층수 변수 생성
- 이상치 분류
가장 고민을 많이 한 부분이다. 단순히 가격이 높다는 것을 기준으로 한다면 특수시장(고가,신축) 관련 데이터가 모두 빠져 나간다. 그렇기에 두가지 방법을 사용해서 각각 진행하였다.
- Isolation Forest
- ML기반 잔차 평균 이상치 판별
# 앙상블 세팅
class EnsembleRegressor(BaseEstimator, RegressorMixin):
def __init__(self, xgb_params=None, cat_params=None):
self.xgb_params = xgb_params
self.cat_params = cat_params
def fit(self, X, y):
xgb_p = self.xgb_params or {}
cat_p = self.cat_params or {}
cat_p_final = {'verbose': False, 'loss_function': 'RMSE', **cat_p}
self.xgb_model = XGBRegressor(**xgb_p)
self.cat_model = CatBoostRegressor(**cat_p_final)
self.xgb_model.fit(X, y)
self.cat_model.fit(X, y)
return self
def predict(self, X):
pred_xgb = self.xgb_model.predict(X)
pred_cat = self.cat_model.predict(X)
return (pred_xgb + pred_cat) / 2
def get_params(self, deep=True):
return {"xgb_params": self.xgb_params, "cat_params": self.cat_params}
def set_params(self, **params):
if "xgb_params" in params:
self.xgb_params = params.pop("xgb_params")
if "cat_params" in params:
self.cat_params = params.pop("cat_params")
super().set_params(**params)
return self
# 메인 함수
def pipeline(input_path: str,
main_output_path: str,
specialty_output_path: str,
quantile_clip: tuple = (0.01, 0.99)):
#데이터 로드
df = pd.read_csv(input_path, low_memory=False)
df['contract_date'] = pd.to_datetime(df['contract_date'])
df = df.sort_values(by='contract_date').reset_index(drop=True)
print(f"데이터: {len(df):,}건")
# 시장 국면 정의
conditions = [
df['contract_year'].between(2010, 2014),
df['contract_year'].between(2015, 2021),
df['contract_year'] >= 2022
]
df['market_regime'] = np.select(conditions, ['회복기', '상승기', '조정기'], default='기타')
# 로그 변환
df['log_price_per_pyeong'] = np.log1p(df['price_per_pyeong'])
#피처 분리
categorical_features = ['market_regime', '행정동명', 'property_use', 'age_type']
numeric_features = ['contract_year']
X = df[categorical_features + numeric_features]
y = df['log_price_per_pyeong']
# 파이프라인 구성
pipeline = Pipeline([
('encoder', TargetEncoder(cols=categorical_features, smoothing=0.3)),
('ensemble', EnsembleRegressor())
])
#
base_xgb_params = {
'subsample': 0.8,
'colsample_bytree': 0.8,
'random_state': 42,
'tree_method': 'gpu_hist',
'eval_metric': 'rmse'
}
base_cat_params = {
'random_state': 42,
'task_type': 'GPU'
}
# Grid 1: XGB
grid_1 = [
{
'ensemble__xgb_params': [{
**base_xgb_params,
'n_estimators': n,
'max_depth': md,
'learning_rate': lr
}],
'ensemble__cat_params': [base_cat_params]
}
for n in [100, 200]
for md in [3, 5, 7]
for lr in [0.1, 0.2]
]
# Grid 2: CatBoost
grid_2 = [
{
'ensemble__xgb_params': [base_xgb_params],
'ensemble__cat_params': [{
**base_cat_params,
'iterations': i,
'depth': d,
'learning_rate': lr
}]
}
for i in [200, 300]
for d in [3, 5, 7]
for lr in [0.1, 0.2]
]
param_grid = grid_1 + grid_2
# GridSearchCV 설정
cv = TimeSeriesSplit(n_splits=3)
grid_search = GridSearchCV(
pipeline,
param_grid=param_grid,
scoring=make_scorer(r2_score),
cv=cv,
n_jobs=1
)
grid_search.fit(X, y)
display(f" 최적 파라미터: {grid_search.best_params_}")
display(f" 최고 교차검증 R²: {grid_search.best_score_:.3f}")
# Out-of-Fold
best_xgb_params = grid_search.best_params_['ensemble__xgb_params']
best_cat_params = grid_search.best_params_['ensemble__cat_params']
final_pipeline = Pipeline([
('encoder', TargetEncoder(cols=categorical_features, smoothing=0.3)),
('ensemble', EnsembleRegressor(xgb_params=best_xgb_params,
cat_params=best_cat_params))
])
pred_cv = np.full(len(y), np.nan)
for fold, (train_idx, test_idx) in enumerate(cv.split(X, y)):
display(f"--- Fold {fold+1}/{cv.get_n_splits()} ---")
X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
y_train = y.iloc[train_idx]
model = clone(final_pipeline)
model.fit(X_train, y_train)
fold_preds = model.predict(X_test)
pred_cv[test_idx] = fold_preds
# 잔차 계산
df['pred_ensemble'] = pred_cv
# NaN 값을 원본 값으로 채워 잔차를 0으로 만듦
df['pred_ensemble'].fillna(df['log_price_per_pyeong'], inplace=True)
df['residual'] = df['log_price_per_pyeong'] - df['pred_ensemble']
# 이상치 경계 계산 (첫 번째 fold 제외)
q_low, q_high = quantile_clip
# 잔차가 0이 아닌 (실제 예측이 수행된) 데이터만 필터링
non_zero_residuals = df[df['residual'] != 0]['residual']
if non_zero_residuals.empty:
print("경고: 유효한 잔차가 없음")
lower, upper = -np.inf, np.inf
else:
# 이 유효한 잔차들만을 기준으로 경계를 계산
lower = non_zero_residuals.quantile(q_low)
upper = non_zero_residuals.quantile(q_high)
display(f"잔차 경계: {q_low*100:.1f}%={lower:.4f}, {q_high*100:.1f}%={upper:.4f}")
# 일반시장 / 특수시장 분리
is_main = df['residual'].between(lower, upper)
# 기본: log_price_per_pyeong 유지
main_df = df[is_main].copy()
specialty_df = df[~is_main].copy()
# 저장
main_df.to_csv(main_output_path, index=False, encoding='utf-8-sig')
specialty_df.to_csv(specialty_output_path, index=False, encoding='utf-8-sig')
display(f" 일반 시장: {len(main_df):,}건 → '{main_output_path}'")
display(f" 특수 시장: {len(specialty_df):,}건 → '{specialty_output_path}'")
display(f" 분리 비율: 일반 {len(main_df)/len(df)*100:.1f}% / 특수 {len(specialty_df)/len(df)*100:.1f}%")
if __name__ == "__main__":
INPUT_PATH = "03_featured_data.csv"
OUTPUT_MAIN = "04_main_market_data.csv"
OUTPUT_SPECIAL = "04_specialty_market_data.csv"
pipeline(
input_path=INPUT_PATH,
main_output_path=OUTPUT_MAIN,
specialty_output_path=OUTPUT_SPECIAL,
quantile_clip=(0.01, 0.99))
3. GWR을 통한 회복기, 상승기, 조정기별 양상



분기별 변화가 있는지 확인하기 위해 시도한 여러 방법 중 하나이다. GWR은 어떤 피쳐가 해당 지역에 얼마나 영향을 미쳤는지 확인 하는 분석 방법 중 하나로 시각화를 통해 간단하게 확인할 수 있다는 장점이 있다.
이러한 GWR을 통해 서울시 아파트 시장을 회복기(2010~2014), 상승기(2015~2021), 조정기(2022~)로 구분해 GWR로 공간적 이질성을 분석했다. 회복기에는 변동성이 상승 기대를 반영하며 가격에 +영향이 나타났고, 상승기에는 거래량 증가가 핵심 상승 동력으로 특정 지역에서 강한 양(+) 효과가 관찰됐다. 조정기에는 변동성이 리스크로 전환되며 음(-) 영향이 확대되었고, 거래량 효과도 둔화되며 지역별 구조 차이가 뚜렷해졌다.
4. 머신러닝 아키텍쳐
1)거래 원천데이터(건별) → 2) 월별 집계(시계열) → 3) 라그/캘린더 피처 생성 → 4) 타깃을 1개월 ahead로 시프트 → 5) 각 타깃별 XGBRegressor 학습/평가(최근 24개월 테스트)
사용된 피쳐
1. 월x동 인덱스
2. 행정동 분포 기반 지표(평균 가격, 가격 표준편, 거래량 가중 평균 가격, 변동계수로 본 상대적 분산
3. 거래 구성비 (주거, 상업 등)
4. 외부 거시 변수 (미분양, 금리, 입주물량)
5. 시간 변수
6. lag 피쳐(1, 2, 3, 6, 12)
5. 결과 및 인사이트
| target | RMSE(m2) | MAPE | NRMSE |
| price_index | 97.54 | 6.45% | 10.74% |
| 단독다가구_price | 57.94 | 7.55% | 10.44% |
| 아파트_price | 90.69 | 6.97% | 8.22% |
| 연립다세대_price | 93.02 | 11.65% | 14.04% |
| 오피스텔_price | 41.25 | 4.52% | 5.53% |
1) 오피스텔은 예측 정확도가 가장 높았다 (MAPE 4.52%, NRMSE 5.53%)
실거주 수요보다 임대 수익 기반의 투자 성격이 강해서 정책, 거시 변수의 단기 충격에 덜 민감한 편이다. 이로 인해 가격 변동 구조가 비교적 단순하며, 월 단위 시계열에서 추세, 관성 효과가 강하게 나타나 lag기반 피쳐가 효과적으로 작동한것으로 보인다.
2) 아파트는 절대 오차 (RMSE)는 크지만 상대 성능은 양호함(NRMSE 8.22%)
아파트는 거래 규모와 가격 레벨 자체가 높기 대문에 RMSE처럼 절대 단위 기반 지표에서는 오차가 크게 나타나기 쉽다. 반면 평균 대비 정규화 지표를 기준으로 보면 상대적으로 낮은 수준으로 전반적인 가격 흐름과 추세는 안정적으로 예측할 수 있다.
3) 연립다세대는 예측 난이도가 가장 높음(MAPE 11.65%, NRMSE 14.04)
연립/다세대는 동일 유형 내에서도 입지, 노후도, 리모델링 여부 등 개별 특성 편차가 크고, 거래량이 아파트 대비 적어 월 단위 집계시 노이즈가 크게 반영될 가능성이 높았다. 또한 특정 지역이나 특정 시점에 거래가 집중되는 구조적 특성이 존재해 단순 lag 기반 시계열 모형에서 설명하기 어려운 변동성이 발생하여 예측 불확실성이 가장 크다.
4) 단독다가구는 절대 오차는 낮지만, 상대 오차는 중간 수준이었다(RMSE 57.94, MAPE 7.55%, NRMSE 10.44%)
단독다가구는 아파트 대비 가격 레벨이 낮거나 거래가 지역적으로 분산되어 RMSE는 상대적으로 작게 나타났다. 다만 NRMSE와 MAPE관점에서는 시장 평균 대비 변동성이 작지 않아 추세 예측은 가능하지만 지역, 개별거래 특성(노후도, 리모델링, 대지지분)으로 인한 비정형 변동이 남아 있는 세그먼트로 해석 할 수 있다.
6. 회고 - 아쉬웠던 점
1) 무엇을 위한 분석인가?
어떤 분석을 실행하기 전에는 어떤 문제를 해결할것인지, 그것을 통해 어떻게 바꿔야 하는지가 정해져 있어야 한다고 생각한다. 이 프로젝트에서는 다양한 기법을 탐색하고 적용하면서 데이터 이해와 피쳐 설계 역량을 키울 수 있었지만 초반에 명확한 목표가 설정되지 않았다.
그 결과 모델 성능을 높이는 것이 목적이 되기도 했고, 기술을 써보기 위한 분석이 목적이 되었다.
2) 기술 선택이 목적이 되면서 리소스가 분산됨
GWR, 다양한 이상치 분리 방법, 다양한 피쳐 엔지니어링 등 여러 기법을 시도했지만, 일부는 필요해서라기 보다는 배운것을 적용해보고 싶어서 선택된 측면이 있다. 학습에는 도움이 되었지만, 프로젝트 완성도 관점에서는 우선순위가 불분명해지고 실행 비용이 증가되었다. 결과적으론 다음 프로젝트(Olist 프로젝트)까지의 시간이 얼마 남지 않아서 황급히 마무리 하면서 완성도도 떨어졌다.
3) 평가 설계가 정답 확인 수준에 머물러 있다.
모델이 실패하는 케이스, 예측 불확실성이 커지는 구간까지 정의할 필요가 있지 않았나 싶다. 국면 전환기에 대한 별도 성능 분석이나 잔차기반 불확실성 측정을 할 수 있도록 추가 분석을 하였다면 더 완성도가 높아졌을것 같다.
4) 정리의 필요성
분석 과정에서 떠오르는 아이디어를 즉시 구현하는 방식으로 진행하다 보니 후반에는 구조를 정리하고 내용을 글로 정리하는 과정에서 시간이 많이 들었다.
얻은점
그럼에도 불구하고 데이터를 직접 탐색하고, 필요한 변수를 만들고, 로직상 논리적이지 않은 부분은 없는지 파악하고, 좀 더 고도화 하기 위해서는 어떻게 해야할지 고민하면서 데이터 기반의 문제 해결의 순서를 몸으로 익힌 프로젝트가 아니였나 하는 생각이 든다.
'공부 기록 > 회고' 카테고리의 다른 글
| 2월 인턴 생활 회고 & AI를 활용한 제작안 구축기 (0) | 2026.02.28 |
|---|---|
| 네번째 프로젝트 [AI기반 카드 추천 모델] (0) | 2026.02.18 |
| 세번째 프로젝트 [낙농업 관리 최적화 프로젝트] (0) | 2026.01.27 |
| 두번째 프로젝트 [Olist 전자상거래 데이터 분석] (0) | 2026.01.18 |
| 첫번째 프로젝트 회고[서울시 부동산 데이터 분석] (0) | 2026.01.17 |