🔥알림🔥
① 테디노트 유튜브 - 구경하러 가기!
② LangChain 한국어 튜토리얼 바로가기 👀
③ 랭체인 노트 무료 전자책(wikidocs) 바로가기 🙌

18 분 소요

머신러닝 알고리즘의 끝판왕인 앙상블(Ensemble) 알고리즘에 대하여 알아보도록 하겠습니다. 앙상블 알고리즘은 방법론 적인 측면에서 Voting, Bagging, Boosting 알고리즘등으로 나뉠 수 있겠고, 앙상블의 앙상블 알고리즘인 Stacking 그리고 Weighted Blending 등의 기법도 알아보도록 하겠습니다. 앙상블 알고리즘은 단일 알고리즘 대비 성능이 좋기 때문에, 캐글(Kaggle.com)과 같은 데이터 분석 대회에서도 상위권에 항상 꼽히는 알고리즘 들입니다.

데이터의 종류, 형태에 따라 어떤 알고리즘이 우세한지는 직접 적용해보고 성능 평가를 통해 알아볼 수는 있겠지만, 최근에는 LGBM 그리고 XGBoost와 같은 부스팅 계열의 알고리즘이 대회의 상위권을 휩쓸고 있습니다. 부스팅 계열의 앙상블 알고리즘이 정형 데이터에서 강세를 보이고 있습니다.

이 튜토리얼의 후반부에는 RandomSearchCVGridSearchCV라는 Hyperparameter 튜닝기도 다루고 있습니다. 물론, 시간과 리소스가 많이 투여되는 부부이기 때문에 최근에는 bayesian optimization을 기반으로 찾는 HyperOpt도 주목 받고 있습니다. 나중에 HyperOpt에 대한 내용도 다뤄보도록 하겠습니다.

튜토리얼은 보스톤 집 값 데이터를 토대로 집 값 예측을 해보는 튜토리얼이며, sklearn.dataset에 포함된 load_boston으로 진행합니다. 따라서, 별도의 파일 데이터는 다운로드 받을 필요 없습니다.

코드

Colab으로 열기 Colab으로 열기

GitHub GitHub에서 소스보기


앙상블(Ensemble)

머신러닝 앙상블이란 여러개의 머신러닝 모델을 이용해 최적의 답을 찾아내는 기법이다.

  • 여러 모델을 이용하여 데이터를 학습하고, 모든 모델의 예측결과를 평균하여 예측

앙상블 기법의 종류

  • 보팅 (Voting): 투표를 통해 결과 도출
  • 배깅 (Bagging): 샘플 중복 생성을 통해 결과 도출
  • 부스팅 (Boosting): 이전 오차를 보완하면서 가중치 부여
  • 스태킹 (Stacking): 여러 모델을 기반으로 예측된 결과를 통해 meta 모델이 다시 한번 예측

실습을 위한 데이터셋 로드

# 튜토리얼 진행을 위한 모듈 import
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import seaborn as sns
from IPython.display import Image

np.set_printoptions(suppress=True, precision=3)
SEED = 30
from sklearn.datasets import load_boston

load_boston 데이터셋 로드

data = load_boston()

컬럼 소개

속성 수 : 13

  • CRIM: 범죄율
  • ZN: 25,000 평방 피트 당 주거용 토지의 비율
  • INDUS: 비소매(non-retail) 비즈니스 면적 비율
  • CHAS: 찰스 강 더미 변수 (통로가 하천을 향하면 1; 그렇지 않으면 0)
  • NOX: 산화 질소 농도 (천만 분의 1)
  • RM:주거 당 평균 객실 수
  • AGE: 1940 년 이전에 건축된 자가 소유 점유 비율
  • DIS: 5 개의 보스턴 고용 센터까지의 가중 거리
  • RAD: 고속도로 접근성 지수
  • TAX: 10,000 달러 당 전체 가치 재산 세율
  • PTRATIO 도시 별 학생-교사 비율
  • B: 1000 (Bk-0.63) ^ 2 여기서 Bk는 도시 별 검정 비율입니다.
  • LSTAT: 인구의 낮은 지위
  • MEDV: 자가 주택의 중앙값 (1,000 달러 단위)
df = pd.DataFrame(data['data'], columns=data['feature_names'])
df['target'] = data['target']
df.head()
CRIM ZN INDUS CHAS NOX RM AGE DIS RAD TAX PTRATIO B LSTAT target
0 0.00632 18.0 2.31 0.0 0.538 6.575 65.2 4.0900 1.0 296.0 15.3 396.90 4.98 24.0
1 0.02731 0.0 7.07 0.0 0.469 6.421 78.9 4.9671 2.0 242.0 17.8 396.90 9.14 21.6
2 0.02729 0.0 7.07 0.0 0.469 7.185 61.1 4.9671 2.0 242.0 17.8 392.83 4.03 34.7
3 0.03237 0.0 2.18 0.0 0.458 6.998 45.8 6.0622 3.0 222.0 18.7 394.63 2.94 33.4
4 0.06905 0.0 2.18 0.0 0.458 7.147 54.2 6.0622 3.0 222.0 18.7 396.90 5.33 36.2

train / test 데이터를 분할 합니다.

from sklearn.model_selection import train_test_split
x_train, x_test, y_train, y_test = train_test_split(df.drop('target', 1), df['target'], random_state=SEED)
x_train.shape, x_test.shape
((379, 13), (127, 13))
x_train.head()
CRIM ZN INDUS CHAS NOX RM AGE DIS RAD TAX PTRATIO B LSTAT
142 3.32105 0.0 19.58 1.0 0.871 5.403 100.0 1.3216 5.0 403.0 14.7 396.90 26.82
10 0.22489 12.5 7.87 0.0 0.524 6.377 94.3 6.3467 5.0 311.0 15.2 392.52 20.45
393 8.64476 0.0 18.10 0.0 0.693 6.193 92.6 1.7912 24.0 666.0 20.2 396.90 15.17
162 1.83377 0.0 19.58 1.0 0.605 7.802 98.2 2.0407 5.0 403.0 14.7 389.61 1.92
363 4.22239 0.0 18.10 1.0 0.770 5.803 89.0 1.9047 24.0 666.0 20.2 353.04 14.64
y_train.head()
142    13.4
10     15.0
393    13.8
162    50.0
363    16.8
Name: target, dtype: float64

모델별 성능 확인을 위한 함수

import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.metrics import mean_squared_error

my_predictions = {}
my_pred = None
my_actual = None
my_name = None

colors = ['r', 'c', 'm', 'y', 'k', 'khaki', 'teal', 'orchid', 'sandybrown',
          'greenyellow', 'dodgerblue', 'deepskyblue', 'rosybrown', 'firebrick',
          'deeppink', 'crimson', 'salmon', 'darkred', 'olivedrab', 'olive', 
          'forestgreen', 'royalblue', 'indigo', 'navy', 'mediumpurple', 'chocolate',
          'gold', 'darkorange', 'seagreen', 'turquoise', 'steelblue', 'slategray', 
          'peru', 'midnightblue', 'slateblue', 'dimgray', 'cadetblue', 'tomato'
         ]

def plot_predictions(name_, pred, actual):
    df = pd.DataFrame({'prediction': pred, 'actual': y_test})
    df = df.sort_values(by='actual').reset_index(drop=True)

    plt.figure(figsize=(11, 8))
    plt.scatter(df.index, df['prediction'], marker='x', color='r')
    plt.scatter(df.index, df['actual'], alpha=0.7, marker='o', color='black')
    plt.title(name_, fontsize=15)
    plt.legend(['prediction', 'actual'], fontsize=12)
    plt.show()

def mse_eval(name_, pred, actual):
    global my_predictions, colors, my_pred, my_actual, my_name
    
    my_name = name_
    my_pred = pred
    my_actual = actual

    plot_predictions(name_, pred, actual)

    mse = mean_squared_error(pred, actual)
    my_predictions[name_] = mse

    y_value = sorted(my_predictions.items(), key=lambda x: x[1], reverse=True)
    
    df = pd.DataFrame(y_value, columns=['model', 'mse'])
    print(df)
    min_ = df['mse'].min() - 10
    max_ = df['mse'].max() + 10
    
    length = len(df) / 2
    
    plt.figure(figsize=(9, length))
    ax = plt.subplot()
    ax.set_yticks(np.arange(len(df)))
    ax.set_yticklabels(df['model'], fontsize=12)
    bars = ax.barh(np.arange(len(df)), df['mse'], height=0.3)
    
    for i, v in enumerate(df['mse']):
        idx = np.random.choice(len(colors))
        bars[i].set_color(colors[idx])
        ax.text(v + 2, i, str(round(v, 3)), color='k', fontsize=12, fontweight='bold', verticalalignment='center')
        
    plt.title('MSE Error', fontsize=16)
    plt.xlim(min_, max_)
    
    plt.show()
    
def add_model(name_, pred, actual):
    global my_predictions, my_pred, my_actual, my_name
    my_name = name_
    my_pred = pred
    my_actual = actual
    
    mse = mean_squared_error(pred, actual)
    my_predictions[name_] = mse

def remove_model(name_):
    global my_predictions
    try:
        del my_predictions[name_]
    except KeyError:
        return False
    return True

def plot_all():
    global my_predictions, my_pred, my_actual, my_name
    
    plot_predictions(my_name, my_pred, my_actual)
    
    y_value = sorted(my_predictions.items(), key=lambda x: x[1], reverse=True)
    
    df = pd.DataFrame(y_value, columns=['model', 'mse'])
    print(df)
    min_ = df['mse'].min() - 10
    max_ = df['mse'].max() + 10
    
    length = len(df) / 2
    
    plt.figure(figsize=(9, length))
    ax = plt.subplot()
    ax.set_yticks(np.arange(len(df)))
    ax.set_yticklabels(df['model'], fontsize=12)
    bars = ax.barh(np.arange(len(df)), df['mse'], height=0.3)
    
    for i, v in enumerate(df['mse']):
        idx = np.random.choice(len(colors))
        bars[i].set_color(colors[idx])
        ax.text(v + 2, i, str(round(v, 3)), color='k', fontsize=12, fontweight='bold', verticalalignment='center')
        
    plt.title('MSE Error', fontsize=16)
    plt.xlim(min_, max_)
    
    plt.show()

단일 회귀예측 모델 (지난 시간)

from sklearn.linear_model import LinearRegression
from sklearn.linear_model import Ridge
from sklearn.linear_model import Lasso
from sklearn.linear_model import ElasticNet
from sklearn.preprocessing import StandardScaler, MinMaxScaler
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import PolynomialFeatures

LinearRegression

  • 기본 옵션 값을 사용하여 학습합니다.
linear_reg = LinearRegression()
linear_reg.fit(x_train, y_train)
pred = linear_reg.predict(x_test)
mse_eval('LinearRegression', pred, y_test)
              model        mse
0  LinearRegression  16.485165

Ridge

  • 규제 계수인 alpha=0.1을 적용합니다.
ridge = Ridge(alpha=0.1)
ridge.fit(x_train, y_train)
pred = ridge.predict(x_test)
mse_eval('Ridge(alpha=0.1)', pred, y_test)
              model        mse
0  LinearRegression  16.485165
1  Ridge(alpha=0.1)  16.479483

Lasso

  • 규제 계수인 alpha=0.01로 적용합니다.
lasso = Lasso(alpha=0.01)
lasso.fit(x_train, y_train)
pred = lasso.predict(x_test)
mse_eval('Lasso(alpha=0.01)', pred, y_test)
               model        mse
0   LinearRegression  16.485165
1   Ridge(alpha=0.1)  16.479483
2  Lasso(alpha=0.01)  16.441822

ElasticNet

  • 규제 계수인 alpha=0.01, l1_ratio=0.8을 적용합니다.
elasticnet = ElasticNet(alpha=0.01, l1_ratio=0.8)
elasticnet.fit(x_train, y_train)
pred = elasticnet.predict(x_test)
mse_eval('ElasticNet(alpha=0.1, l1_ratio=0.8)', pred, y_test)    
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822

Pipeline 학습

StandardScaler와 ElasticNet의 파이프라인 학습 합니다.

  • ElasticNet 모델은 규제 계수인 alpha=0.01, l1_ratio=0.8을 적용합니다.
elasticnet_pipeline = make_pipeline(
    StandardScaler(),
    ElasticNet(alpha=0.01, l1_ratio=0.8)
)
elasticnet_pipeline.fit(x_train, y_train)
elasticnet_pred = elasticnet_pipeline.predict(x_test)
mse_eval('Standard ElasticNet', elasticnet_pred, y_test)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137

PolynomialFeatures

PolynomialFeatures와 ElasticNet의 파이프라인 학습을 진행합니다.

  • PolynomialFeatures는 degree=2, include_bias=False로 적용합니다.
  • ElasticNet 모델은 규제 계수인 alpha=0.1, l1_ratio=0.2을 적용합니다.
poly_pipeline = make_pipeline(
    PolynomialFeatures(degree=2, include_bias=False),
    ElasticNet(alpha=0.1, l1_ratio=0.2)
)
poly_pipeline.fit(x_train, y_train)
poly_pred = poly_pipeline.predict(x_test)
mse_eval('Poly ElasticNet', poly_pred, y_test)
/home/ubuntu/anaconda3/envs/tensorflow2_p36/lib/python3.6/site-packages/sklearn/linear_model/coordinate_descent.py:475: ConvergenceWarning: Objective did not converge. You might want to increase the number of iterations. Duality gap: 1748.27446730776, tolerance: 3.4587142691292874
  positive)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137
5                      Poly ElasticNet  10.147479

앙상블 (Ensemble)

보팅 (Voting) - 회귀 (Regression)

Voting은 단어 뜻 그대로 투표를 통해 결정하는 방식입니다. Voting은 Bagging과 투표방식이라는 점에서 유사하지만, 다음과 같은 큰 차이점이 있습니다.

  • Voting은 다른 알고리즘 model을 조합해서 사용합니다.
  • Bagging은 같은 알고리즘 내에서 다른 sample 조합을 사용합니다.
from sklearn.ensemble import VotingRegressor, VotingClassifier

반드시, Tuple 형태로 모델을 정의해야 합니다.

single_models = [
    ('linear_reg', linear_reg), 
    ('ridge', ridge), 
    ('lasso', lasso), 
    ('elasticnet_pipeline', elasticnet_pipeline), 
    ('poly_pipeline', poly_pipeline)
]
voting_regressor = VotingRegressor(single_models, n_jobs=-1)
voting_regressor.fit(x_train, y_train)
VotingRegressor(estimators=[('linear_reg',
                             LinearRegression(copy_X=True, fit_intercept=True,
                                              n_jobs=None, normalize=False)),
                            ('ridge',
                             Ridge(alpha=0.1, copy_X=True, fit_intercept=True,
                                   max_iter=None, normalize=False,
                                   random_state=None, solver='auto',
                                   tol=0.001)),
                            ('lasso',
                             Lasso(alpha=0.01, copy_X=True, fit_intercept=True,
                                   max_iter=1000, normalize=False,
                                   positive=Fals...
                                      steps=[('polynomialfeatures',
                                              PolynomialFeatures(degree=2,
                                                                 include_bias=False,
                                                                 interaction_only=False,
                                                                 order='C')),
                                             ('elasticnet',
                                              ElasticNet(alpha=0.1, copy_X=True,
                                                         fit_intercept=True,
                                                         l1_ratio=0.2,
                                                         max_iter=1000,
                                                         normalize=False,
                                                         positive=False,
                                                         precompute=False,
                                                         random_state=None,
                                                         selection='cyclic',
                                                         tol=0.0001,
                                                         warm_start=False))],
                                      verbose=False))],
                n_jobs=-1, weights=None)
voting_pred = voting_regressor.predict(x_test)
mse_eval('Voting Ensemble', voting_pred, y_test)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137
5                      Voting Ensemble  13.179718
6                      Poly ElasticNet  10.147479

보팅 (Voting) - 분류 (Classification)

분류기 모델을 만들때, Voting 앙상블은 1가지의 중요한 parameter가 있습니다.

voting = {'hard', 'soft'}

hard로 설정한 경우

class를 0, 1로 분류 예측을 하는 이진 분류를 예로 들어 보겠습니다.

Hard Voting 방식에서는 결과 값에 대한 다수 class를 차용합니다.

classification을 예로 들어 보자면, 분류를 예측한 값이 1, 0, 0, 1, 1 이었다고 가정한다면 1이 3표, 0이 2표를 받았기 때문에 Hard Voting 방식에서는 1이 최종 값으로 예측을 하게 됩니다.

soft

soft vote 방식은 각각의 확률의 평균 값을 계산한다음에 가장 확률이 높은 값으로 확정짓게 됩니다.

가령 class 0이 나올 확률이 (0.4, 0.9, 0.9, 0.4, 0.4)이었고, class 1이 나올 확률이 (0.6, 0.1, 0.1, 0.6, 0.6) 이었다면,

  • class 0이 나올 최종 확률은 (0.4+0.9+0.9+0.4+0.4) / 5 = 0.44,
  • class 1이 나올 최종 확률은 (0.6+0.1+0.1+0.6+0.6) / 5 = 0.4

가 되기 때문에 앞선 Hard Vote의 결과와는 다른 결과 값이 최종 으로 선출되게 됩니다.

from sklearn.ensemble import VotingClassifier
from sklearn.linear_model import LogisticRegression, RidgeClassifier
models = [
    ('Logi', LogisticRegression()), 
    ('ridge', RidgeClassifier())
]

voting 옵션에 대하여 지정합니다.

vc = VotingClassifier(models, voting='hard')

배깅(Bagging)

Bagging은 Bootstrap Aggregating의 줄임말입니다.

  • Bootstrap = Sample(샘플) + Aggregating = 합산

Bootstrap은 여러 개의 dataset을 중첩을 허용하게 하여 샘플링하여 분할하는 방식

데이터 셋의 구성이 [1, 2, 3, 4, 5 ]로 되어 있다면,

  1. group 1 = [1, 2, 3]
  2. group 2 = [1, 3, 4]
  3. group 3 = [2, 3, 5]
Image('https://teddylee777.github.io/images/2019-12-17/image-20191217015537872.png')

Voting VS Bagging

  • Voting은 여러 알고리즘의 조합에 대한 앙상블
  • Bagging은 하나의 단일 알고리즘에 대하여 여러 개의 샘플 조합으로 앙상블

대표적인 Bagging 앙상블

  1. RandomForest
  2. Bagging

RandomForest

  • DecisionTree(트리)기반 Bagging 앙상블
  • 굉장히 인기있는 앙상블 모델
  • 사용성이 쉽고, 성능도 우수함
from sklearn.ensemble import RandomForestRegressor, RandomForestClassifier
rfr = RandomForestRegressor()
rfr.fit(x_train, y_train)
/home/ubuntu/anaconda3/envs/tensorflow2_p36/lib/python3.6/site-packages/sklearn/ensemble/forest.py:245: FutureWarning: The default value of n_estimators will change from 10 in version 0.20 to 100 in 0.22.
  "10 in version 0.20 to 100 in 0.22.", FutureWarning)
RandomForestRegressor(bootstrap=True, criterion='mse', max_depth=None,
                      max_features='auto', max_leaf_nodes=None,
                      min_impurity_decrease=0.0, min_impurity_split=None,
                      min_samples_leaf=1, min_samples_split=2,
                      min_weight_fraction_leaf=0.0, n_estimators=10,
                      n_jobs=None, oob_score=False, random_state=None,
                      verbose=0, warm_start=False)
rfr_pred = rfr.predict(x_test)
mse_eval('RandomForest Ensemble', rfr_pred, y_test)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137
5                      Voting Ensemble  13.179718
6                      Poly ElasticNet  10.147479
7                RandomForest Ensemble   7.838730

주요 Hyperparameter

  • random_state: 랜덤 시드 고정 값. 고정해두고 튜닝할 것!
  • n_jobs: CPU 사용 갯수
  • max_depth: 깊어질 수 있는 최대 깊이. 과대적합 방지용
  • n_estimators: 앙상블하는 트리의 갯수
  • max_features: 최대로 사용할 feature의 갯수. 과대적합 방지용
  • min_samples_split: 트리가 분할할 때 최소 샘플의 갯수. default=2. 과대적합 방지용
Image('https://teddylee777.github.io/images/2020-01-09/decistion-tree.png', width=600)

튜닝을 할 땐 반드시 random_state 값을 고정시킵니다!

rfr = RandomForestRegressor(random_state=42, n_estimators=1000, max_depth=7, max_features=0.9)
rfr.fit(x_train, y_train)
rfr_pred = rfr.predict(x_test)
mse_eval('RandomForest Ensemble w/ Tuning', rfr_pred, y_test)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137
5                      Voting Ensemble  13.179718
6                      Poly ElasticNet  10.147479
7                RandomForest Ensemble   7.838730
8      RandomForest Ensemble w/ Tuning   7.080034

부스팅 (Boosting)

Boosting 알고리즘 역시 앙상블 학습 (ensemble learning)이며, 약한 학습기를 순차적으로 학습을 하되, 이전 학습에 대하여 잘못 예측된 데이터에 가중치를 부여해 오차를 보완해 나가는 방식입니다.

다른 앙상블 기법과 가장 다른 점중 하나는 바로 순차적인 학습을 하며 weight를 부여해서 오차를 보완해 나간다는 점인데요. 순차적이기 때문에 병렬 처리에 어려움이 있고, 그렇기 때문에 다른 앙상블 대비 학습 시간이 오래걸린다는 단점이 있습니다.

약한 학습기를 순차적으로 학습을 하되, 이전 학습에 대하여 잘못 예측된 데이터에 가중치를 부여해 오차를 보완해 나가는 방식입니다.

장점

  • 성능이 매우 우수하다 (Lgbm, XGBoost)

단점

  • 부스팅 알고리즘의 특성상 계속 약점(오분류/잔차)을 보완하려고 하기 때문에 잘못된 레이블링이나 아웃라이어에 필요 이상으로 민감할 수 있다
  • 다른 앙상블 대비 학습 시간이 오래걸린다는 단점이 존재
Image('https://keras.io/img/graph-kaggle-1.jpeg', width=800)

대표적인 Boosting 앙상블

  1. AdaBoost
  2. GradientBoost
  3. LightGBM (LGBM)
  4. XGBoost

GradientBoost

  • 성능이 우수함
  • 학습시간이 해도해도 너무 느리다
from sklearn.ensemble import GradientBoostingRegressor, GradientBoostingClassifier
gbr = GradientBoostingRegressor(random_state=42)
gbr.fit(x_train, y_train)
gbr_pred = gbr.predict(x_test)
mse_eval('GradientBoost Ensemble', gbr_pred, y_test)
                                 model        mse
0  ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
1                     LinearRegression  16.485165
2                     Ridge(alpha=0.1)  16.479483
3                    Lasso(alpha=0.01)  16.441822
4                  Standard ElasticNet  16.423137
5                      Voting Ensemble  13.179718
6                      Poly ElasticNet  10.147479
7                RandomForest Ensemble   7.838730
8               GradientBoost Ensemble   7.321397
9      RandomForest Ensemble w/ Tuning   7.080034

주요 Hyperparameter

  • random_state: 랜덤 시드 고정 값. 고정해두고 튜닝할 것!
  • n_jobs: CPU 사용 갯수
  • learning_rate: 학습율. 너무 큰 학습율은 성능을 떨어뜨리고, 너무 작은 학습율은 학습이 느리다. 적절한 값을 찾아야함. n_estimators와 같이 튜닝. default=0.1
  • n_estimators: 부스팅 스테이지 수. (랜덤포레스트 트리의 갯수 설정과 비슷한 개념). default=100
  • subsample: 샘플 사용 비율 (max_features와 비슷한 개념). 과대적합 방지용
  • min_samples_split: 노드 분할시 최소 샘플의 갯수. default=2. 과대적합 방지용
gbr = GradientBoostingRegressor(random_state=SEED, learning_rate=0.01)
gbr.fit(x_train, y_train)
gbr_pred = gbr.predict(x_test)
mse_eval('GradientBoosting(lr=0.01)', gbr_pred, y_test)
                                  model        mse
0             GradientBoosting(lr=0.01)  17.215704
1   ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                      LinearRegression  16.485165
3                      Ridge(alpha=0.1)  16.479483
4                     Lasso(alpha=0.01)  16.441822
5                   Standard ElasticNet  16.423137
6                       Voting Ensemble  13.179718
7                       Poly ElasticNet  10.147479
8                 RandomForest Ensemble   7.838730
9                GradientBoost Ensemble   7.321397
10      RandomForest Ensemble w/ Tuning   7.080034
gbr = GradientBoostingRegressor(random_state=SEED, learning_rate=0.01, n_estimators=1000)
gbr.fit(x_train, y_train)
gbr_pred = gbr.predict(x_test)
mse_eval('GradientBoosting(lr=0.01, est=1000)', gbr_pred, y_test)
                                  model        mse
0             GradientBoosting(lr=0.01)  17.215704
1   ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                      LinearRegression  16.485165
3                      Ridge(alpha=0.1)  16.479483
4                     Lasso(alpha=0.01)  16.441822
5                   Standard ElasticNet  16.423137
6                       Voting Ensemble  13.179718
7                       Poly ElasticNet  10.147479
8                 RandomForest Ensemble   7.838730
9                GradientBoost Ensemble   7.321397
10      RandomForest Ensemble w/ Tuning   7.080034
11  GradientBoosting(lr=0.01, est=1000)   6.917041
gbr = GradientBoostingRegressor(random_state=SEED, learning_rate=0.01, n_estimators=1000, subsample=0.8)
gbr.fit(x_train, y_train)
gbr_pred = gbr.predict(x_test)
mse_eval('GradientBoosting(lr=0.01, est=1000, subsample=0.8)', gbr_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                    RandomForest Ensemble w/ Tuning   7.080034
11                GradientBoosting(lr=0.01, est=1000)   6.917041
12  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496

XGBoost

eXtreme Gradient Boosting

주요 특징

  • scikit-learn 패키지가 아닙니다.
  • 성능이 우수함
  • GBM보다는 빠르고 성능도 향상되었습니다.
  • 여전히 학습시간이 매우 느리다
from xgboost import XGBRegressor, XGBClassifier
xgb = XGBRegressor(random_state=SEED)
xgb.fit(x_train, y_train)
xgb_pred = xgb.predict(x_test)
mse_eval('XGBoost', xgb_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                    RandomForest Ensemble w/ Tuning   7.080034
11                GradientBoosting(lr=0.01, est=1000)   6.917041
12                                            XGBoost   6.878353
13  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496

주요 Hyperparameter

  • random_state: 랜덤 시드 고정 값. 고정해두고 튜닝할 것!
  • n_jobs: CPU 사용 갯수
  • learning_rate: 학습율. 너무 큰 학습율은 성능을 떨어뜨리고, 너무 작은 학습율은 학습이 느리다. 적절한 값을 찾아야함. n_estimators와 같이 튜닝. default=0.1
  • n_estimators: 부스팅 스테이지 수. (랜덤포레스트 트리의 갯수 설정과 비슷한 개념). default=100
  • max_depth: 트리의 깊이. 과대적합 방지용. default=3.
  • subsample: 샘플 사용 비율. 과대적합 방지용. default=1.0
  • max_features: 최대로 사용할 feature의 비율. 과대적합 방지용. default=1.0
xgb = XGBRegressor(random_state=42, learning_rate=0.01, n_estimators=1000, subsample=0.8, max_features=0.8, max_depth=7)
xgb.fit(x_train, y_train)
xgb_pred = xgb.predict(x_test)
mse_eval('XGBoost w/ Tuning', xgb_pred, y_test)
[00:44:10] WARNING: /workspace/src/learner.cc:480: 
Parameters: { max_features } might not be used.

  This may not be accurate due to some parameters are only used in language bindings but
  passed down to XGBoost core.  Or some parameters are not used but slip through this
  verification. Please open an issue if you find above cases.


                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                    RandomForest Ensemble w/ Tuning   7.080034
11                GradientBoosting(lr=0.01, est=1000)   6.917041
12                                            XGBoost   6.878353
13  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496
14                                  XGBoost w/ Tuning   5.178889

LightGBM

주요 특징

  • scikit-learn 패키지가 아닙니다 (Microsoft 사 개발)
  • 부스팅 계열의 알고리즘 입니다.
  • 성능이 우수함

특이점

  • 기존 부스팅 계열 알고리즘이 가지는 단점인 느린 학습 속도를 개선하였습니다.
from lightgbm import LGBMRegressor, LGBMClassifier
lgbm = LGBMRegressor(random_state=SEED)
lgbm.fit(x_train, y_train)
lgbm_pred = lgbm.predict(x_test)
mse_eval('LGBM', lgbm_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                                               LGBM   7.300779
11                    RandomForest Ensemble w/ Tuning   7.080034
12                GradientBoosting(lr=0.01, est=1000)   6.917041
13                                            XGBoost   6.878353
14  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496
15                                  XGBoost w/ Tuning   5.178889

주요 Hyperparameter

  • random_state: 랜덤 시드 고정 값. 고정해두고 튜닝할 것!
  • n_jobs: CPU 사용 갯수
  • learning_rate: 학습율. 너무 큰 학습율은 성능을 떨어뜨리고, 너무 작은 학습율은 학습이 느리다. 적절한 값을 찾아야함. n_estimators와 같이 튜닝. default=0.1
  • n_estimators: 부스팅 스테이지 수. (랜덤포레스트 트리의 갯수 설정과 비슷한 개념). default=100
  • max_depth: 트리의 깊이. 과대적합 방지용. default=3.
  • colsample_bytree: 샘플 사용 비율 (max_features와 비슷한 개념). 과대적합 방지용. default=1.0
lgbm = LGBMRegressor(random_state=SEED, learning_rate=0.01, n_estimators=1500, colsample_bytree=0.9, num_leaves=15, subsample=0.8)
lgbm.fit(x_train, y_train)
lgbm_pred = lgbm.predict(x_test)
mse_eval('LGBM w/ Tuning', lgbm_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                                               LGBM   7.300779
11                                     LGBM w/ Tuning   7.118697
12                    RandomForest Ensemble w/ Tuning   7.080034
13                GradientBoosting(lr=0.01, est=1000)   6.917041
14                                            XGBoost   6.878353
15  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496
16                                  XGBoost w/ Tuning   5.178889

Stacking

개별 모델이 예측한 데이터를 기반으로 final_estimator 종합하여 예측을 수행합니다.

  • 성능을 극으로 끌어올릴 때 활용하기도 합니다.
  • 과대적합을 유발할 수 있습니다. (특히, 데이터셋이 적은 경우)
import sklearn
sklearn.__version__
'0.21.3'
from sklearn.ensemble import StackingRegressor
---------------------------------------------------------------------------
ImportError                               Traceback (most recent call last)
<ipython-input-48-9484580cd08e> in <module>()
----> 1 from sklearn.ensemble import StackingRegressor

ImportError: cannot import name 'StackingRegressor'
stack_models = [
    ('elasticnet', poly_pipeline), 
    ('randomforest', rfr), 
    ('gbr', gbr),
    ('lgbm', lgbm),
]
stack_reg = StackingRegressor(stack_models, final_estimator=xgb, n_jobs=-1)
stack_reg.fit(x_train, y_train)
stack_pred = stack_reg.predict(x_valid)
mse_eval('Stacking Ensemble', stack_pred, y_valid)

Weighted Blending

각 모델의 예측값에 대하여 weight를 곱하여 최종 output 계산

  • 모델에 대한 가중치를 조절하여, 최종 output을 산출합니다.
  • 가중치의 합은 1.0이 되도록 합니다.
final_outputs = {
    'elasticnet': poly_pred, 
    'randomforest': rfr_pred, 
    'gbr': gbr_pred,
    'xgb': xgb_pred, 
    'lgbm': lgbm_pred,
    'stacking': stack_pred,
}
final_prediction=\
final_outputs['elasticnet'] * 0.1\
+final_outputs['randomforest'] * 0.1\
+final_outputs['gbr'] * 0.2\
+final_outputs['xgb'] * 0.25\
+final_outputs['lgbm'] * 0.15\
+final_outputs['stacking'] * 0.2\
mse_eval('Weighted Blending', final_prediction, y_valid)

앙상블 모델을 정리하며

  1. 앙상블은 대체적으로 단일 모델 대비 성능이 좋습니다.
  2. 앙상블을 앙상블하는 기법인 Stacking과 Weighted Blending도 참고해 볼만 합니다.
  3. 앙상블 모델은 적절한 Hyperparameter 튜닝이 중요합니다.
  4. 앙상블 모델은 대체적으로 학습시간이 더 오래 걸립니다.
  5. 따라서, 모델 튜닝을 하는 데에 걸리는 시간이 오래 소요됩니다.

검증 (Validation)과 튜닝 (Tuning)

Cross Validation

  • Cross Validation이란 모델을 평가하는 하나의 방법입니다.
  • K-겹 교차검증(K-fold Cross Validation)을 많이 활용합니다.

K-겹 교차검증

  • K-겹 교차 검증은 모든 데이터가 최소 한 번은 테스트셋으로 쓰이도록 합니다. 아래의 그림을 보면, 데이터를 5개로 쪼개 매번 테스트셋을 바꿔나가는 것을 볼 수 있습니다.

[예시]

  • Estimation 1일때,

학습데이터: [B, C, D, E] / 검증데이터: [A]

  • Estimation 2일때,

학습데이터: [A, C, D, E] / 검증데이터: [B]

Image('https://static.packt-cdn.com/products/9781789617740/graphics/b04c27c5-7e3f-428a-9aa6-bb3ebcd3584c.png', width=800)

K-Fold Cross Validation

from sklearn.model_selection import KFold
n_splits = 5
kfold = KFold(n_splits=n_splits, random_state=SEED)
df.head()
CRIM ZN INDUS CHAS NOX RM AGE DIS RAD TAX PTRATIO B LSTAT target
0 0.00632 18.0 2.31 0.0 0.538 6.575 65.2 4.0900 1.0 296.0 15.3 396.90 4.98 24.0
1 0.02731 0.0 7.07 0.0 0.469 6.421 78.9 4.9671 2.0 242.0 17.8 396.90 9.14 21.6
2 0.02729 0.0 7.07 0.0 0.469 7.185 61.1 4.9671 2.0 242.0 17.8 392.83 4.03 34.7
3 0.03237 0.0 2.18 0.0 0.458 6.998 45.8 6.0622 3.0 222.0 18.7 394.63 2.94 33.4
4 0.06905 0.0 2.18 0.0 0.458 7.147 54.2 6.0622 3.0 222.0 18.7 396.90 5.33 36.2
X = np.array(df.drop('target', 1))
Y = np.array(df['target'])
lgbm_fold = LGBMRegressor(random_state=SEED)
i = 1
total_error = 0
for train_index, test_index in kfold.split(X):
    x_train_fold, x_valid_fold = X[train_index], X[test_index]
    y_train_fold, y_valid_fold = Y[train_index], Y[test_index]
    lgbm_pred_fold = lgbm_fold.fit(x_train_fold, y_train_fold).predict(x_valid_fold)
    error = mean_squared_error(lgbm_pred_fold, y_valid_fold)
    print('Fold = {}, prediction score = {:.2f}'.format(i, error))
    total_error += error
    i+=1
print('---'*10)
print('Average Error: %s' % (total_error / n_splits))
Fold = 1, prediction score = 9.00
Fold = 2, prediction score = 15.73
Fold = 3, prediction score = 18.18
Fold = 4, prediction score = 43.95
Fold = 5, prediction score = 24.96
------------------------------
Average Error: 22.36329584390587

Hyperparameter 튜닝

  • hypterparameter 튜닝시 경우의 수가 너무 많습니다.
  • 따라서, 우리는 자동화할 필요가 있습니다.

sklearn 패키지에서 자주 사용되는 hyperparameter 튜닝을 돕는 클래스는 다음 2가지가 있습니다.

  1. RandomizedSearchCV
  2. GridSearchCV

적용하는 방법

  1. 사용할 Search 방법을 선택합니다.
  2. hyperparameter 도메인을 설정합니다. (max_depth, n_estimators..등등)
  3. 학습을 시킨 후, 기다립니다.
  4. 도출된 결과 값을 모델에 적용하고 성능을 비교합니다.

RandomizedSearchCV

  • 모든 매개 변수 값이 시도되는 것이 아니라 지정된 분포에서 고정 된 수의 매개 변수 설정이 샘플링됩니다.
  • 시도 된 매개 변수 설정의 수는 n_iter에 의해 제공됩니다.

주요 Hyperparameter (LGBM)

  • random_state: 랜덤 시드 고정 값. 고정해두고 튜닝할 것!
  • n_jobs: CPU 사용 갯수
  • learning_rate: 학습율. 너무 큰 학습율은 성능을 떨어뜨리고, 너무 작은 학습율은 학습이 느리다. 적절한 값을 찾아야함. n_estimators와 같이 튜닝. default=0.1
  • n_estimators: 부스팅 스테이지 수. (랜덤포레스트 트리의 갯수 설정과 비슷한 개념). default=100
  • max_depth: 트리의 깊이. 과대적합 방지용. default=3.
  • colsample_bytree: 샘플 사용 비율 (max_features와 비슷한 개념). 과대적합 방지용. default=1.0
params = {
    'n_estimators': [200, 500, 1000, 2000], 
    'learning_rate': [0.1, 0.05, 0.01], 
    'max_depth': [6, 7, 8], 
    'colsample_bytree': [0.8, 0.9, 1.0], 
    'subsample': [0.8, 0.9, 1.0],
}
from sklearn.model_selection import RandomizedSearchCV

n_iter 값을 조절하여, 총 몇 회의 시도를 진행할 것인지 정의합니다.

(회수가 늘어나면, 더 좋은 parameter를 찾을 확률은 올라가지만, 그만큼 시간이 오래걸립니다.)

clf = RandomizedSearchCV(LGBMRegressor(), params, random_state=42, cv=3, n_iter=25, scoring='neg_mean_squared_error')
clf.fit(x_train, y_train)
/home/ubuntu/anaconda3/envs/tensorflow2_p36/lib/python3.6/site-packages/sklearn/model_selection/_search.py:814: DeprecationWarning: The default of the `iid` parameter will change from True to False in version 0.22 and will be removed in 0.24. This will change numeric results when test-set sizes are unequal.
  DeprecationWarning)
RandomizedSearchCV(cv=3, error_score='raise-deprecating',
                   estimator=LGBMRegressor(boosting_type='gbdt',
                                           class_weight=None,
                                           colsample_bytree=1.0,
                                           importance_type='split',
                                           learning_rate=0.1, max_depth=-1,
                                           min_child_samples=20,
                                           min_child_weight=0.001,
                                           min_split_gain=0.0, n_estimators=100,
                                           n_jobs=-1, num_leaves=31,
                                           objective=None, random_state=None,
                                           reg_alpha=0.0, reg_...
                                           subsample_for_bin=200000,
                                           subsample_freq=0),
                   iid='warn', n_iter=25, n_jobs=None,
                   param_distributions={'colsample_bytree': [0.8, 0.9, 1.0],
                                        'learning_rate': [0.1, 0.05, 0.01],
                                        'max_depth': [6, 7, 8],
                                        'n_estimators': [200, 500, 1000, 2000],
                                        'subsample': [0.8, 0.9, 1.0]},
                   pre_dispatch='2*n_jobs', random_state=42, refit=True,
                   return_train_score=False, scoring='neg_mean_squared_error',
                   verbose=0)
clf.best_score_
-14.00618653261075
clf.best_params_
{'subsample': 0.9,
 'n_estimators': 200,
 'max_depth': 8,
 'learning_rate': 0.1,
 'colsample_bytree': 0.8}
lgbm_best = LGBMRegressor(n_estimators=2000, subsample=0.8, max_depth=7, learning_rate=0.01, colsample_bytree=0.8)
lgbm_best.fit(x_train, y_train)
lgbm_best_pred = lgbm_best.predict(x_test)
mse_eval('RandomSearch LGBM', lgbm_best_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                               RandomForest Ensemble   7.838730
9                              GradientBoost Ensemble   7.321397
10                                               LGBM   7.300779
11                                  RandomSearch LGBM   7.211968
12                                     LGBM w/ Tuning   7.118697
13                    RandomForest Ensemble w/ Tuning   7.080034
14                GradientBoosting(lr=0.01, est=1000)   6.917041
15                                            XGBoost   6.878353
16  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496
17                                  XGBoost w/ Tuning   5.178889

GridSearchCV

  • 모든 매개 변수 값에 대하여 완전 탐색을 시도합니다.
  • 따라서, 최적화할 parameter가 많다면, 시간이 매우 오래걸립니다.
params = {
    'n_estimators': [500, 1000], 
    'learning_rate': [0.1, 0.05, 0.01], 
    'max_depth': [7, 8], 
    'colsample_bytree': [0.8, 0.9], 
    'subsample': [0.8, 0.9,],
}
from sklearn.model_selection import GridSearchCV
grid_search = GridSearchCV(LGBMRegressor(), params, cv=3, n_jobs=-1, scoring='neg_mean_squared_error')
grid_search.fit(x_train, y_train)
/home/ubuntu/anaconda3/envs/tensorflow2_p36/lib/python3.6/site-packages/sklearn/model_selection/_search.py:814: DeprecationWarning: The default of the `iid` parameter will change from True to False in version 0.22 and will be removed in 0.24. This will change numeric results when test-set sizes are unequal.
  DeprecationWarning)
GridSearchCV(cv=3, error_score='raise-deprecating',
             estimator=LGBMRegressor(boosting_type='gbdt', class_weight=None,
                                     colsample_bytree=1.0,
                                     importance_type='split', learning_rate=0.1,
                                     max_depth=-1, min_child_samples=20,
                                     min_child_weight=0.001, min_split_gain=0.0,
                                     n_estimators=100, n_jobs=-1, num_leaves=31,
                                     objective=None, random_state=None,
                                     reg_alpha=0.0, reg_lambda=0.0, silent=True,
                                     subsample=1.0, subsample_for_bin=200000,
                                     subsample_freq=0),
             iid='warn', n_jobs=-1,
             param_grid={'colsample_bytree': [0.8, 0.9],
                         'learning_rate': [0.1, 0.05, 0.01],
                         'max_depth': [7, 8], 'n_estimators': [500, 1000],
                         'subsample': [0.8, 0.9]},
             pre_dispatch='2*n_jobs', refit=True, return_train_score=False,
             scoring='neg_mean_squared_error', verbose=0)
abs(grid_search.best_score_)
14.144994643093849
grid_search.best_params_
{'colsample_bytree': 0.8,
 'learning_rate': 0.1,
 'max_depth': 8,
 'n_estimators': 500,
 'subsample': 0.8}
lgbm_best = LGBMRegressor(**grid_search.best_params_)
lgbm_best.fit(x_train, y_train)
lgbm_best_pred = lgbm_best.predict(x_test)
mse_eval('GridSearch LGBM', lgbm_best_pred, y_test)
                                                model        mse
0                           GradientBoosting(lr=0.01)  17.215704
1                 ElasticNet(alpha=0.1, l1_ratio=0.8)  16.638817
2                                    LinearRegression  16.485165
3                                    Ridge(alpha=0.1)  16.479483
4                                   Lasso(alpha=0.01)  16.441822
5                                 Standard ElasticNet  16.423137
6                                     Voting Ensemble  13.179718
7                                     Poly ElasticNet  10.147479
8                                     GridSearch LGBM   8.030239
9                               RandomForest Ensemble   7.838730
10                             GradientBoost Ensemble   7.321397
11                                               LGBM   7.300779
12                                  RandomSearch LGBM   7.211968
13                                     LGBM w/ Tuning   7.118697
14                    RandomForest Ensemble w/ Tuning   7.080034
15                GradientBoosting(lr=0.01, est=1000)   6.917041
16                                            XGBoost   6.878353
17  GradientBoosting(lr=0.01, est=1000, subsample=...   6.480496
18                                  XGBoost w/ Tuning   5.178889

댓글남기기