본문 바로가기

공부방/Upstage AI Lab 4기

아파트실거래가 예측 코드에 MLflow 덧붙이기

+추가
공부하면서 알게됐는데, 범주형 변수들 인코딩하는거!! train, test 나눈 다음에 해야한다!

 

 

저번 프로젝트를 제대로 정리하고 공부해야하는데 또 다른 새로운 프로젝트를 시작해야하는 상황이다. ㅋㅋㅋㅋㅋ이런게 부트캠프인가봐.

제대로 정리하기 전에 일단 코드라도 옮겨놓으면서 잠시 복습해보았다. 


1. 데이터 불러오기
아파트실거래가 데이터와 우리팀에서 몇 가지 피쳐를 덧붙인 최종 파일 real_final.csv를 불러온다. (test데이터와 train데이터가 합쳐져 있고, is_test가 0이면 train, 1이면 test로 구분되어 있다.) 
그리고 데이터 내 피쳐들을 보면서 몇 가지 조정을 해준다. (해제사유발생일은 0 또는 1로 바꿔준다거나, 구매당시의 연식을 나타내는 피쳐를 파생변수로 만들어준다거나.. 그런 것들. 그리고 각 구에 해당하는 백화점 숫자를 int형으로 바꿔줬다.) 원래 여기서 뭔가 더 해볼까 했는데, 일단 시간이 없어 패스 -

#라이브러리 임포트
import pandas as pd
import numpy as np
from tqdm import tqdm
import pickle
import warnings;warnings.filterwarnings('ignore')

# Model
from sklearn.preprocessing import LabelEncoder
from sklearn.model_selection import train_test_split
from sklearn.metrics import mean_squared_error
from sklearn.ensemble import RandomForestRegressor
from sklearn import metrics

import eli5
from eli5.sklearn import PermutationImportance

#데이터 불러오기
concat = pd.read_csv('/data/ephemeral/home/data/real_final.csv')
concat['is_test'].value_counts()

'''
0    1118822
1       9272
'''

concat.shape #(1128094, 43)

concat.columns
'''
Index(['아파트명', '전용면적', '계약년월', '계약일', '층', '건축년도', '도로명', '해제사유발생일',
       'k-단지분류(아파트,주상복합등등)', 'k-건설사(시공사)', '건축면적', 'target', 'is_test', '주소',
       'x', 'y', '주변공원개수', 'Gu_column', 'contractyear', 'Department', 'CPI',
       'GDP', '3y_KTB_yield', 'base_rate', 'unemployment_rate',
       'youth_unemployment_rate', '단위면적당가격', '자치구별_사설학원_학생_비율',
       '자치구별_교과학원_학생_비율', '공시가격', 'stations_walkable_dist',
       'nearest_station_distance', 'nearest_station_name',
       'nearest_busstop_distance', 'busstops_walkable_dist', '전세가격지수',
       '주택전세_소비자심리지수', '부동산시장_소비자심리지수', '지가지수', '주택매매_소비자심리지수', 'apt_매매지수',
       '규모', '규모별매매지수'],
      dtype='object')
'''

#변수 추가 또는 형식 바꾸기
df = concat.copy()

#결측치 많아서 미리 제거
columns_to_drop = ['건축면적','주택전세_소비자심리지수', '부동산시장_소비자심리지수', '주택매매_소비자심리지수']
df = df.drop(columns=columns_to_drop)

#해제사유 발생일 -> 값 존재 1 없으면 0
df['해제사유발생일'] = np.where(df['해제사유발생일'].isna(), 0, 1)

#구매당시 연식 구하는 코드
df['건축년도'] = pd.to_datetime(df['건축년도'], format='%Y')
df['계약년월'] = pd.to_datetime(df['계약년월'].astype(str), format='%Y%m')
df['구매당시연식'] = df['계약년월'].dt.year - df['건축년도'].dt.year
df['구매당시연식'] = np.where(df['구매당시연식'] < 0, 0, df['구매당시연식'])

#백화점 숫자는 범주형이 아닌데 object로 분류되어서 범주형으로 들어감. 
def convert_to_int(value):
    if value == '-':
        return 0
    return int(value)

df['Department'] = df['Department'].apply(convert_to_int)

 

2. 결측치를 채운다.
처음에는 train과 test를 나눈 뒤에 train 데이터만 해도 되겠지..? 했는데, 클로드한테 물어보니 어쨌든 결측치는 채우긴 해야 하는 것 같다. 클로드 말이 "실제 환경에서는 결측치가 있을 수 있으므로, 테스트 세트에도 결측치를 그대로 두는 것이 더 현실적일 수 있습니다."라고.ㅋㅋㅋㅋ 하지만 어쨌든 테스트에 결측치가 있다면 모델이나 전체 파이프라인에서 결측치를 채울 수 있도록 코드를 작성해야 한다.  이번 경우는 트레인과 테스트를 나눈 채로 트레인의 결측치만 모두 보간하고, 테스트는 하지 않은 채로 그냥 진행했다. (나중에 트레인과 테스트 합친채로 결측치 채우고 모델 학습시키기 직전에 나눴는데, 그 결과가 미세하게 좋게 나왔다. 테스트와 트레인에 일관성있도록 전처리를 할 것!)

트레인에만 결측치 채운채로 학습시킨 것
트레인과 테스트 합친 채로 결측치를 채운 뒤 학습시킨 것

 

그리고 RandomForestRegressor와 같은 트리 기반 모델은 결측치를 잘 처리한다. 

공시가격은 선형회귀를 적용시켜서 결측치를 채웠고, x, y좌표로 나타난 위도와 경도는 주소에 같은 동을 찾아서 그 동의 x, y 좌표를 똑같이 쓰게 했다. (물론 똑같은 동 안에서도 x, y좌표가 많았는데 그냥 맨 처음 나오는 값으로 채워줌.) 이렇게 해도 남아있는 x, y 좌표 결측치는 선형보간했다. 아파트명이라는 피쳐에도 결측치가 있었는데 얘는 어차피 문자라서 레이블 인코딩하면 큰 의미가 없을 듯 하여, null이라는 글자를 넣어줬다. 

 

test_data = df.query('is_test==1')
train_data = df.query('is_test==0')

#결측치 채우기
#공시가격 결측치 선형회귀 적용시키기

from sklearn import linear_model
from sklearn.ensemble import HistGradientBoostingRegressor

def reg(data):
    # lin_reg = linear_model.LinearRegression() 
    model = HistGradientBoostingRegressor()
    X = data.dropna(axis=0)[['전용면적', '계약일', '층', '해제사유발생일', 'x', 'y', '주변공원개수', 'contractyear', 'CPI', 'GDP', '3y_KTB_yield', 'base_rate', 'unemployment_rate', 'youth_unemployment_rate', '단위면적당가격', '자치구별_사설학원_학생_비율', '자치구별_교과학원_학생_비율', '공시가격', 'stations_walkable_dist', 'nearest_station_distance', 'nearest_busstop_distance', 'busstops_walkable_dist', '전세가격지수', '지가지수', 'apt_매매지수', '규모별매매지수', '구매당시연식']] 
    y = data.dropna(axis=0)['공시가격']
    lin_reg_model = model.fit(X, y)
    y_pred = lin_reg_model.predict(data.loc[:,['전용면적', '계약일', '층', '해제사유발생일', 'x', 'y', '주변공원개수', 'contractyear', 'CPI', 'GDP', '3y_KTB_yield', 'base_rate', 'unemployment_rate', 'youth_unemployment_rate', '단위면적당가격', '자치구별_사설학원_학생_비율', '자치구별_교과학원_학생_비율', '공시가격', 'stations_walkable_dist', 'nearest_station_distance', 'nearest_busstop_distance', 'busstops_walkable_dist', '전세가격지수', '지가지수', 'apt_매매지수', '규모별매매지수', '구매당시연식']])
    data = data['공시가격'].fillna(pd.Series(y_pred.flatten()), inplace=True)
    return data

reg(train_data)

#x, y좌표 결측치 채워주기(같은 동 이름을 기준으로, 좌표값 복사)

def extract_road_name(address):
    return ''.join([c for c in address if not c.isdigit()]).strip()

def fill_missing_coordinates_safe(df):
    # 진행 상황을 확인하기 위한 전체 행 수 계산
    total_rows = len(df)
    
    # 도로명 컬럼 추가
    print("도로명 추출 중...")
    df['road_name'] = df['도로명'].apply(extract_road_name)
    
    # 결측값이 없는 데이터에서 도로명별 대표 좌표 계산
    print("도로명별 대표 좌표 계산 중...")
    valid_coords = df.dropna(subset=['x', 'y']).groupby('road_name').first().reset_index()[['road_name', 'x', 'y']]
    
    # 결측값 채우기
    print("결측값 채우기 중...")
    for i, row in df[df['x'].isna() | df['y'].isna()].iterrows():
        match = valid_coords[valid_coords['road_name'] == row['road_name']]
        if not match.empty:
            df.loc[i, 'x'] = match.iloc[0]['x']
            df.loc[i, 'y'] = match.iloc[0]['y']
        
        # 진행 상황 출력 (1000행마다)
        if i % 1000 == 0:
            print(f"진행 중: {i}/{total_rows} 행 처리됨")
    
    # 임시 컬럼 삭제
    df = df.drop('road_name', axis=1)
    
    print("처리 완료!")
    return df

# 함수 사용
train_data = fill_missing_coordinates_safe(train_data)

# 결과 확인
print(train_data[['주소', 'x', 'y']].head())
print(f"남은 결측치 개수: x - {train_data['x'].isna().sum()}, y - {train_data['y'].isna().sum()}")


#남은 결측치 선형보간 및 null 입력
train_data[xy] = train_data[xy].interpolate(method='linear', axis=0)
train_data['아파트명'] = train_data['아파트명'].fillna('NULL')

 

3. 스케일링
연속형 변수 중에서 스케일링이 필요할 것 같은 피쳐들을 스케일링했다. 이날 팀원들끼리 저녁에 모여앉아서 피쳐마다 뭘로 스케일링 하면 좋을지 고민했던 게 생각나네(흐뭇)

#스케일링
from scipy import stats
from sklearn.preprocessing import MinMaxScaler, RobustScaler

continuous_columns = []
categorical_columns = []

for column in train_data.columns:
    if pd.api.types.is_numeric_dtype(train_data[column]):
        continuous_columns.append(column)
    else:
        categorical_columns.append(column)
print(continuous_columns)

def scale_columns(df, minmax_cols=None, robust_cols=None, boxcox_cols=None):
    """
    주어진 컬럼들에 대해 MinMax Scaler, Robust Scaler, Box-Cox 변환을 적용하는 함수.
    
    Parameters:
    df (DataFrame): 원본 데이터 프레임
    minmax_cols (list): MinMax Scaling을 적용할 컬럼 리스트
    robust_cols (list): Robust Scaling을 적용할 컬럼 리스트
    boxcox_cols (list): Box-Cox 변환을 적용할 컬럼 리스트
    
    Returns:
    DataFrame: 스케일링 또는 변환이 완료된 데이터 프레임
    dict: Box-Cox 변환에 사용된 lambda 값
    """
    
    
    # 복사본 생성
    df_scaled = df.copy()
    lambda_dict = {}  # Box-Cox 변환에 사용된 lambda 값을 저장할 딕셔너리
    
    # MinMax Scaling 적용
    if minmax_cols:
        minmax_scaler = MinMaxScaler()
        df_scaled[minmax_cols] = minmax_scaler.fit_transform(df_scaled[minmax_cols])
    
    # Robust Scaling 적용
    if robust_cols:
        robust_scaler = RobustScaler()
        df_scaled[robust_cols] = robust_scaler.fit_transform(df_scaled[robust_cols])
    
    # Box-Cox 변환 적용
    if boxcox_cols:
        for col in boxcox_cols:
            # Box-Cox는 데이터에 0 또는 음수 값이 있으면 안 됨.
            if (df_scaled[col] <= 0).any():
                raise ValueError(f"Box-Cox 변환은 음수 또는 0 값을 가질 수 없습니다. '{col}' 컬럼에 음수나 0이 있습니다.")
            
            # Box-Cox 변환 수행
            df_scaled[col], lambda_value = stats.boxcox(df_scaled[col])
            lambda_dict[col] = lambda_value  # lambda 값을 저장
    
    return df_scaled, lambda_dict

# MinMax Scaling을 적용할 컬럼 리스트
# 사용할 스케일링 방법과 컬럼 리스트
# 비율 제외

minmax_columns = ['Department',
                  #경제지표
                  #실업율
                  'unemployment_rate','youth_unemployment_rate',
                  '전세가격지수', 'CPI','GDP',
                  '지가지수', '규모별매매지수', '구매당시연식' ]

robust_columns = ['stations_walkable_dist', 'x', 'y', 
                  'nearest_station_distance', 'nearest_busstop_distance', 
                  'busstops_walkable_dist']

boxcox_columns = ['전용면적', '공시가격',
                  '3y_KTB_yield','base_rate']

# 스케일링 함수 호출
scaled_df, lambda_dict = scale_columns(df=train_data, 
                                       minmax_cols=minmax_columns, 
                                       robust_cols=robust_columns, 
                                       boxcox_cols=boxcox_columns)

# 결과 출력
print("Box-Cox 변환에 사용된 Lambda 값:", lambda_dict)

 

4. 레이블 인코딩

# 이제 is_test 칼럼은 drop해줍니다.
train_data.drop(['is_test'], axis = 1, inplace=True)
test_data.drop(['is_test'], axis = 1, inplace=True)
print(train_data.shape, test_data.shape)

test_data['target'] = 0

# 파생변수 제작으로 추가된 변수들이 존재하기에, 다시한번 연속형과 범주형 칼럼을 분리해주겠습니다.
continuous_columns_v2 = []
categorical_columns_v2 = []

for column in train_data.columns:
    if pd.api.types.is_numeric_dtype(train_data[column]):
        continuous_columns_v2.append(column)
    else:
        categorical_columns_v2.append(column)

print("연속형 변수:", continuous_columns_v2)
print("범주형 변수:", categorical_columns_v2)

for col in categorical_columns_v2:
    train_data[col] = train_data[col].astype("str")
    test_data[col] = test_data[col].astype("str")

# 아래에서 범주형 변수들을 대상으로 레이블인코딩을 진행해 주겠습니다.

# 각 변수에 대한 LabelEncoder를 저장할 딕셔너리
label_encoders = {}

# Implement Label Encoding
for col in tqdm( categorical_columns_v2 ):
    lbl = LabelEncoder()

    # Label-Encoding을 fit
    lbl.fit( train_data[col].astype(str) )
    train_data[col] = lbl.transform(train_data[col].astype(str))
    label_encoders[col] = lbl           # 나중에 후처리를 위해 레이블인코더를 저장해주겠습니다.

    # Test 데이터에만 존재하는 새로 출현한 데이터를 신규 클래스로 추가해줍니다.
    for label in np.unique(test_data[col]):
      if label not in lbl.classes_: # unseen label 데이터인 경우
        lbl.classes_ = np.append(lbl.classes_, label) # 미처리 시 ValueError발생하니 주의하세요!

    test_data[col] = lbl.transform(test_data[col].astype(str))

 

5. 모델 돌리기

mlflow를 쪼금 배웠으니 넣어봤다. 그런데 아까까지 mlflow ui가 잘 나왔는데 지금은 원만 계속 돌아가고 안뜬다ㅠ

3개 모델을 돌렸는데, 결과는 맨 위 스크린샷으로 첨부.

# Target과 독립변수들을 분리해줍니다.
y_train = train_data['target'].copy()
X_train = train_data.drop(['target'], axis=1)

# Hold out split을 사용해 학습 데이터와 검증 데이터를 8:2 비율로 나누겠습니다.
X_train, X_val, y_train, y_val = train_test_split(
    X_train, y_train, test_size=0.2, random_state=2023)

import mlflow
mlflow.set_tracking_uri('http://127.0.0.1:5000')
exp = mlflow.set_experiment(experiment_name='apt-price')

from catboost import CatBoostRegressor, Pool
from sklearn.metrics import mean_squared_error
import xgboost as xgb

# 알고리즘별 성능 테스트
models = {
    "CatBoostRegressor":CatBoostRegressor(
        iterations=1000,  # 학습 반복 횟수
        learning_rate=0.05,  # 학습률
        depth=8,  # 트리 깊이
        l2_leaf_reg = 1,
        loss_function='RMSE',  # 손실 함수  # 학습 과정 출력
        cat_features=categorical_columns_v2,
        task_type='GPU',
        early_stopping_rounds=50
    ),
    "RandomForestRegressor":RandomForestRegressor(
        n_estimators=5, 
        criterion='squared_error', 
        random_state=1, 
        n_jobs=-1
    ),
    "xgb.XGBRegressor":xgb.XGBRegressor(
        n_estimators=500, 
        max_depth=9, 
        min_child_weight=5, 
        gamma=0.1, 
        n_jobs=-1
    )
}

mlflow.autolog()

for model_name, model in models.items():
    with mlflow.start_run():
        model.fit(X_train, y_train)
        pred = model.predict(X_val)
        rmse = np.sqrt(mean_squared_error(y_val, pred))
        print(f'{model_name} RMSE test: {rmse}')

 

다음 번에는 Cross Validation 이용하는 부분 연습해봐야겠다.