ML

[ML] 머신러닝 프로젝트 맛보기 2

tenacy 2022. 6. 7. 23:50
💡
이 글은 <핸즈온 머신러닝 2판> 의 ‘CHAPTER2’ 을 공부하며 정리한 내용이 서술되어 있습니다.
📌
이 글에는 위 책의 범위에 대한 모든 내용이 서술되어 있지 않습니다. 단순히 제가 더 자세히 알아보고 싶은 내용만 서술되어 있으며, 이 또한 정확한 정보가 아니니 읽는 데 주의 바랍니다.

 

머신러닝 알고리즘을 위한 데이터 준비

실습에 들어가기 전에 예측 변수와 타깃값에 같은 변형을 적용하지 않기 위해 예측 변수와 레이블을 분리하겠습니다.

 

housing = strat_train_set.drop("median_house_value", axis=1)
housing_labels = strat_train_set["median_house_value"].copy()

 

데이터 정제

앞서 total_bedrooms에 누락된 값이 있다는 것을 확인했습니다. 이를 해결할 수 있는 세 가지 방법이 있습니다.

 

  • 해당 구역을 제거합니다.
    • dropna()
    • ex. total_bedrooms에 NaN(Not a Number)인 값이 있다면 그 행을 제거합니다.


    • housing.dropna(subset=["total_bedrooms"]).info()
  • 전체 특성을 삭제합니다.
    • drop()
    • ex. total_bedrooms 특성을 제거합니다.


    • housing.drop("total_bedrooms", axis=1)
  • 어떤 값으로 채웁니다(0, 평균, 중간값 등).
    • fillna()
    • ex. total_bedrooms의 NaN인 값을 평균값으로 대체합니다.


    • median = housing["total_bedrooms"].median() housing["total_bedrooms"].fillna(median, inplace=True) housing.info()

사이킷런의 SimpleImputer는 누락된 값을 손쉽게 다루도록 해줍니다. SimpleImputer 객체를 생성합니다.

 

from sklearn.impute import SimpleImputer

imputer = SimpleImputer(strategy="median")

 

중간값은 수치형 특성에서만 계산될 수 있기 때문에 ocean_proximity는 제외합니다. fit 메서드를 사용해 훈련 데이터에 적용합니다.

 

housing_num = housing.drop("ocean_proximity", axis=1)
imputer.fit(housing_num)

 

imputer의 statistics_ 혹은 values로 계산된 중간값을 확인할 수 있습니다. imputer는 각 특성의 중간값을 계산해서 그 결과를 객체의 statistics_ 속성에 저장합니다.

 

imputer.statistics_

 

housing_num.median().values

 

 

transform()으로 특성들을 변형시켜 넘파이 배열에 저장하고, 이를 데이터프레임으로 되돌립니다.

 

X = imputer.transform(housing_num)
housing_tr = pd.DataFrame(X, columns=housing_num.columns, index=housing_num.index)
housing_tr

 

 


사이킷런의 설계 철학에 대해 생각해봅시다.


 

텍스트와 범주형 특성

텍스트 특성을 다룰 때에는 텍스트를 숫자로 변환하면 됩니다. 그러면 기존에 수치형 특성을 다뤘을 때와 동일한 과정을 진행할 수 있는 것이죠. 이를 위해 사이킨런은 OrdinalEncoder 클래스를 제공합니다.

 

from sklearn.preprocessing import OrdinalEncoder

housing_cat = housing[["ocean_proximity"]]
ordinal_encoder = OrdinalEncoder()
housing_cat_encoded = ordinal_encoder.fit_transform(housing_cat)
housing_cat_encoded[:10]

 

 

categories_ 인스턴스 변수를 사용해 카테고리 목록을 얻을 수 있습니다.

 

ordinal_encoder.categories_

 

 

하지만, OrdinalEncoder의 표현 방식에는 한 가지 문제가 있습니다. 머신러닝 알고리즘이 가까이 있는 두 값이 떨어져 있는 두 값보다 더 비슷하다고 생각한다는 점입니다. 순서가 있는 카테고리의 경우에는 상관이 없지만, 우리의 카테고리(ocean_proximity)는 문제가 됩니다. 이는 원-핫 인코딩으로 해결할 수 있습니다.

 

원-핫 인코딩은 한 특성만 1이고, 나머지를 0이 되도록 텍스트를 인코딩하는 방식입니다.

원-핫 인코딩은 연속성을 필요로 하지 않습니다. 때문에 인코딩할 범주형 특성이 꼭 연속적일 필요가 없는 것이죠. 사이킷런은 범주의 값을 원-핫 벡터로 바꾸기 위한 OneHotEncoder 클래스를 제공합니다.

 

from sklearn.preprocessing import OneHotEncoder

cat_encoder = OneHotEncoder()
housing_cat_1hot = cat_encoder.fit_transform(housing_cat)
housing_cat_1hot

 

 

변환된 값이 넘파이 배열이 아닌 사이파이 희소 행렬이라는 것에 주목합시다. 이는 수천 개의 카테고리가 있는 범주형 특성일 경우 매우 효율적입니다. 원-핫 인코딩은 카테고리 수가 매우 많을 경우, 0이 차지하는 공간이 많아 메모리가 낭비되는 단점이 있습니다. 하지만, 희소 행렬은 0이 아닌 원소의 위치만 저장하므로, 메모리를 효율적으로 사용할 수 있습니다. 이 행렬은 거의 일반적인 2차원 배열처럼 사용할 수 있지만 넘파이 배열로 바꾸어야 하는 경우 toarray() 메서드를 사용하면 됩니다.

 

housing_cat_1hot.toarray()

 

 

나만의 변환기

특별한 정제 작업이나 특성 조합 등의 작업을 위해 저희만의 변환기를 만들어 봅시다. 사이킷런은 덕 타이핑을 지원하므로 fit(), transform(), fit_transform() 메서드를 구현한 파이썬 클래스를 만들면 됩니다. 참고로, 덕 타이핑은 상속이나 인터페이스 구현이 아니라 객체의 속성이나 메서드가 객체의 유형을 결정하는 방식을 말합니다.

 


파이썬 클래스 개념이 잡혀있는지 점검해볼까요?


 

from sklearn.base import BaseEstimator, TransformerMixin

rooms_ix, bedrooms_ix, population_ix, households_ix = 3, 4, 5, 6

class CombinedAttributesAdder(BaseEstimator, TransformerMixin):
    def __init__(self, add_bedrooms_per_room = True):
        self.add_bedrooms_per_room = add_bedrooms_per_room
    def fit(self, X, y=None):
        return self
    def transform(self, X):
        rooms_per_household = X[:, rooms_ix] / X[:, households_ix]
        population_per_household = X[:, population_ix] / X[:, households_ix]
        if self.add_bedrooms_per_room:
            bedrooms_per_room = X[:, bedrooms_ix] / X[:, rooms_ix]
            return np.c_[X, rooms_per_household, population_per_household, bedrooms_per_room]
        
        else:
            return np.c_[X, rooms_per_household, population_per_household]

        
attr_adder = CombinedAttributesAdder(add_bedrooms_per_room=False)
housing_extra_attribs = attr_adder.transform(housing.values)

 

현재 CombinedAttributesAdderadd_bedrooms_per_room라는 인스턴스 변수 하나를 가집니다. 이는 하나의 하이퍼파라미터로 작용하며, 이 값에 따라 머신러닝 알고리즘에 도움이 될 지 안 될 지를 알 수 있습니다. 즉, 하이퍼파라미터 튜닝에 시간과 비용을 아낄 수 있는 것이죠. 이렇게 데이터 준비 단계를 자동화할수록 더 많은 조합을 자동으로 시도해볼 수 있고, 최상의 조합을 찾을 가능성을 매우 높여줍니다.

 

특성 스케일링

트리 기반 알고리즘을 제외하고는 머신러닝 알고리즘은 입력 숫자 특성들의 스케일이 많이 다르면 잘 작동하지 않습니다.

 

모든 특성의 범위를 같도록 만들어주는 방법으로 min-max 스케일링표준화가 널리 사용됩니다. min-max 스케일링은 0~1 범위에 들도록 값을 이동하고 스케일을 조정하면 됩니다. 사이킷런에서는 이를 위해 MinMaxScaler 변환기를 제공합니다. feature_range 매개변수로 0~1 범위가 아닌 다른 범위로 변경할 수도 있습니다. 표준화는 평균을 뺀 후 표준편차로 나누어 결과 분포의 분산이 1이 되도록 합니다. 표준화는 상한과 하한이 없어 입력값의 범위로 0에서 1 사이를 기대하는 신경망 같은 알고리즘에서는 문제가 될 수 있습니다. 하지만 표준화는 이상치에 영향을 덜 받습니다. 사이킷런에서는 이를 위해 StandardScaler 변환기를 제공합니다.

 

변환 파이프라인

머신러닝에서는 변환 단계가 많습니다. 단계의 순서도 중요합니다. 사이킷런에서는 연속된 변환을 순서대로 처리할 수 있도록 도와주는 Pipeline 클래스가 있습니다.

 

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

num_pipeline = Pipeline([
    ('imputer', SimpleImputer(strategy="median")),
    ('attribs_adder', CombinedAttributesAdder()),
    ('std_scaler', StandardScaler()),
])

housing_num_tr = num_pipeline.fit_transform(housing_num)

 

Pipeline의 마지막 단계에는 추정을 할 수 있어야 하므로 변환기와 추정기를 모두 사용할 수 있고, 그 외에는 모두 변환기여야 합니다.

 

하나의 변환기로도 각 열마다 적절한 변환을 적용하여 모든 열을 처리할 수도 있습니다. 사이킷런에서는 이를 위해 ColumnTransformer를 제공합니다.

 

from sklearn.compose import ColumnTransformer

num_attribs = list(housing_num)
cat_attribs = ["ocean_proximity"]

full_pipeline = ColumnTransformer([
    ("num", num_pipeline, num_attribs),
    ("cat", OneHotEncoder(), cat_attribs),
])

housing_prepared = full_pipeline.fit_transform(housing)

 

여기서 중요한 점은 변환기는 동일한 개수의 행을 반환해야 한다는 점입니다.

 

OneHotEncoder는 희소 행렬을 반환하는 데 비해 num_pipeline은 밀집 행렬(대부분의 값이 0이 아닌 값으로 채워진 행렬)을 반환합니다. 이런 상황에서 ColumnTransformer는 최종 행렬의 밀집 정도를 추정하는데, 밀집도가 임곗값(기본은 0.3)보다 낮으면 희소 행렬을 반환합니다. 자, 이처럼 전체 주택 데이터를 받아 각 열에 적절한 변환을 적용하는 전처리 파이프라인을 만들었습니다.

 

모델 선택과 훈련

조금은 위험한 훈련 데이터셋으로 평가까지 하기

이제 머신러닝 모델을 선택하고 훈련시킬 차례입니다.

 

some_data = housing.iloc[:5]
some_labels = housing_labels.iloc[:5]
some_data_prepared = full_pipeline.transform(some_data)

print("예측:", lin_reg.predict(some_data_prepared))
print("레이블:", list(some_labels))

 

 

예측이 썩 좋지는 않습니다. 사이킷런의 mean_square_error 함수를 사용해 전체 훈련 세트에 대한 이 회귀 모델의 RMSE를 측정해보겠습니다.

 

from sklearn.metrics import mean_squared_error

housing_predictions = lin_reg.predict(housing_prepared)
lin_mse = mean_squared_error(housing_labels, housing_predictions)
lin_rmse = np.sqrt(lin_mse)
lin_rmse

 

 

역시나 좋지 않네요. 앞서 describe()로 숫자형 특성의 요약을 확인했을 때, 대부분의 중간 주택 가격은 120,000(제1사분위수) 달러에서 265,000(제3사분위수) 달러 사이에 있었습니다. 그러므로 예측 오차가 68,378 달러인 것은 확실히 좋지 않습니다. 이는 모델이 너무 단순하거나 데이터의 특성들이 좋은 예측을 만들 만큼 충분한 정보를 제공하지 못해서 발생한 과소적합의 사례로 볼 수 있습니다. 해결책은 이미 알고 있습니다. 더 강력한 모델을 선택하거나, 훈련 알고리즘에 더 좋은 데이터를 주입하면 됩니다.

 

조금 더 복잡한 모델을 사용해봅시다.

DecisionTreeRegressor를 훈련시켜보겠습니다. 이 모델에 대해서 현재로서는 자세히 몰라도 됩니다. 더 복잡한 모델이 현재 우리가 직면한 문제를 해결해 줄 수 있는지 없는지만 확인해봅시다.

 

from sklearn.tree import DecisionTreeRegressor

tree_reg = DecisionTreeRegressor()
tree_reg.fit(housing_prepared, housing_labels)
housing_predictions = tree_reg.predict(housing_prepared)
tree_mse = mean_squared_error(housing_labels, housing_predictions)
tree_rmse = np.sqrt(tree_mse)
tree_rmse

 

 

??? 아니, 이렇게 쉽게 문제를 완벽하게 해결…이 아니라 저희는 현재 훈련 데이터셋밖에 사용하지 않았습니다. 과대적합의 경우도 생각해 볼 수 있겠습니다. 아직 확신할 수는 없는 것이죠. 테스트 데이터셋은 모델 훈련을 마치고, 딱 한 번만 사용할 것이기 때문에 대신 검증을 진행해보겠습니다.

 

교차 검증을 사용한 평가

결정 트리 모델은 어떻게 평가해야 할까요? 일단 일반적인 방법으로는 앞서 배웠듯이, 사이킷런의 train_test_split으로 훈련 데이터셋을 나누어 검증을 진행할 수 있습니다.

 

하지만 더 좋은 대안으로 k-겹 교차 검증이라는 기능을 사용할 수도 있습니다.

훈련 데이터셋을 폴드라 불리는 10개의 서브셋으로 무작위로 분할하여 결정 트리 모델을 10번 훈련하고 평가해봅시다. 이 때, 매번 다른 폴드를 선택해 평가에 사용하고 나머지 9개 폴드는 훈련에 사용합니다.

 

from sklearn.model_selection import cross_val_score

scores = cross_val_score(tree_reg, housing_prepared, housing_labels, scoring="neg_mean_squared_error", cv=10)
tree_rmse_scores = np.sqrt(-scores)

 

참고로, 사이킷런의 교차 검증은 scoring 매개변수에 클수록 좋은 효용 함수를 기대하기 때문에 MSE의 반댓값을 계산하는 neg_mean_squared_error 함수를 사용합니다.

 

def display_scores(scores):
    print("점수:", scores)
    print("평균:", scores.mean())
    print("표준편차:", scores.std())

display_scores(tree_rmse_scores)

 

 

앞서, 훈련 데이터로 평가해서 얻은 결과는 모두 거짓이었습니다.

제대로 과대적합된 것이었죠. 그래도 위 결과에서 교차 검증을 진행하면 이 추정이 얼마나 정확한지까지도 알 수 있었습니다. 교차 검증을 할 때 유의할 점은 모델을 여러 번 훈련시켜야 하므로 비용이 비싸다는 것입니다. 언제나 교차 검증을 쓸 수 있는 것은 아니라는 것이죠.

 

선형 회귀 모델에 대해서도 교차 검증을 해보겠습니다.

 

lin_scores = cross_val_score(lin_reg, housing_prepared, housing_labels, scoring="neg_mean_squared_error", cv=10)
lin_rmse_scores = np.sqrt(-lin_scores)
display_scores(lin_rmse_scores)

 

 

확실히 결정 트리 모델이 과대적합되어 선형 회귀 모델보다 성능이 나쁩니다. 마지막으로 RandomForestRegressor 모델을 하나 더 시도해봅시다.

 

from sklearn.ensemble import RandomForestRegressor

forest_reg = RandomForestRegressor()
forest_reg.fit(housing_prepared, housing_labels)

 

housing_predictions = forest_reg.predict(housing_prepared)
forest_mse = mean_squared_error(housing_labels, housing_predictions)
forest_rmse = np.sqrt(forest_mse)
forest_rmse

 

 

forest_scores = cross_val_score(forest_reg, housing_prepared, housing_labels, scoring="neg_mean_squared_error", cv=10)
forest_rmse_scores = np.sqrt(-forest_scores)
display_scores(forest_rmse_scores)

 

 

랜덤 포레스트가 훨씬 좋은 성능을 보입니다. 하지만 여전히 과대적합되어 있습니다. 지금까지, 여러 모델을 시도해봤습니다.

 

모델 세부 튜닝

가능성 있는 모델들을 추렸다면 세부 튜닝 작업에 들어갑니다.

 

그리드 탐색

이 단계에서 우리의 목표는 최적의 하이퍼파라미터 조합을 찾는 것입니다. 브루트포스 알고리즘처럼 모든 경우의 수를 탐색하는 것이 가장 간단한 해결책이겠죠. 시간이 너무 오래 걸린다는 단점이 있지만요.

 

사이킷런은 이를 위해 GridSearchCV라는 클래스를 제공합니다. 여기서 CV는 Cross Validation(교차 검증)의 약자입니다. 이는 입력값을 기반으로 가능한 모든 하이퍼파라미터 조합에 대해 교차 검증을 사용해 평가합니다. RandomForestRegressor에 대해 최적의 하이퍼파라미터 조합을 탐색해보겠습니다.

 

from sklearn.model_selection import GridSearchCV

#n_estimators: 생성할 의사 나무 결정 개수
#bootstrap: 매개변수 샘플을 랜덤하게 뽑는지
param_grid = [
    {'n_estimators': [3, 10, 30], 'max_features': [2, 4, 6, 8]},
    {'bootstrap': [False], 'n_estimators': [3, 10], 'max_features': [2, 3, 4]},
]

forest_reg = RandomForestRegressor()

grid_search = GridSearchCV(forest_reg, param_grid, cv=5, scoring='neg_mean_squared_error', return_train_score=True)

grid_search.fit(housing_prepared, housing_labels)

 

 

첫 번째 하이퍼파라미터 조합을 탐색할 때, 3x4=12개를 평가하고, 두 번째 하이퍼파라미터 조합을 탐색할 때에는 1x2x3=6개를 평가하므로 총 12+6=18개를 평가합니다. 또한 5-겹 교차 검증을 사용하기 때문에 전체 훈련 횟수는 18x5=90이 됩니다. 이제 최적의 조합을 확인해봅시다.

 

grid_search.best_params_

 

 

최적의 추정기도 확인해봅시다.

 

grid_search.best_estimator_

 

 

평가 점수도 확인할 수 있습니다.

 

cvres = grid_search.cv_results_
for mean_score, params in zip(cvres["mean_test_score"], cvres["params"]):
    print(np.sqrt(-mean_score), params)

 

 

이제 최적의 모델을 찾아봅시다.

 

cvres = grid_search.cv_results_
for mean_score, params in zip(cvres["mean_test_score"], cvres["params"]):
    if(min(-cvres["mean_test_score"]) == -mean_score):
        print(np.sqrt(-mean_score), params)

 

 

최적의 모델을 찾았습니다.

 

랜덤 탐색

탐색 공간이 커지면 그리드 탐색 방법은 너무 오래 걸릴 수 있습니다. 그 때에는 RandomizedSearchCV를 사용합니다. GridSearchCV와 거의 같은 방식이지만 가능한 모든 조합을 시도하지 않고, 반복 시마다 하이퍼파라미터에 임의의 수를 대입하여 지정한 횟수만큼 평가합니다.

 


랜덤 탐색은 잘 와닿지가 않습니다. 정확히 이해할 필요가 있습니다.


 

앙상블 방법

모델의 그룹이 최상의 단일 모델보다 더 나은 성능을 발휘할 때가 많습니다.

 

최상의 모델과 오차 분석

앞서 그리드 탐색을 사용하여 최적의 모델을 찾아본 바 있습니다. 최상의 모델을 분석하면 문제에 대한 좋은 통찰을 얻는 경우가 많습니다.

 

최상의 모델의 특성의 상대적인 중요도를 확인해봅시다.

 

feature_importances = grid_search.best_estimator_.feature_importances_
feature_importances

 

 

중요도에 대응하는 특성 이름도 같이 확인해봅니다. 이 때 범주형의 경우에는 각 값을 중요도에 대응시킵니다.

 

extra_attribs = ["rooms_per_hhold", "pop_per_hhold", "bedrooms_per_room"]
cat_encoder = full_pipeline.named_transformers_["cat"]
cat_one_hot_attribs = list(cat_encoder.categories_[0])
attributes = num_attribs + extra_attribs + cat_one_hot_attribs
sorted(zip(feature_importances, attributes), reverse=True)

 

 

이를 통해 중요하지 않은 특성을 알 수 있고, 이 특성들은 제외할 수 있겠죠.

 

테스트 세트로 시스템 평가하기

드디어 테스트 데이터셋으로 최종 모델을 평가할 차례입니다. 일단 테스트 데이터셋도 훈련 데이터셋에서 진행한 작업을 그대로 적용합니다. 예측 변수와 레이블을 얻고, 파이프라인을 사용해 데이터를 변환하고 최종 모델을 평가합니다.

 

final_model = grid_search.best_estimator_

X_test = strat_test_set.drop("median_house_value", axis=1)
y_test = strat_test_set["median_house_value"].copy()

X_test_prepared = full_pipeline.transform(X_test)

final_predictions = final_model.predict(X_test_prepared)

final_mse = mean_squared_error(y_test, final_predictions)
final_rmse = np.sqrt(final_mse)
final_rmse

 

이러한 결과를 가지고 론칭을 결정하는 건 맞지만 기존 시스템에 있는 모델과 별로 차이가 안 나는 경우에는 어떨까요? 이러한 결과를 신뢰할 수 있는지에 대한 문제로 바뀔 것입니다. 이를 위해 scipy.stats.t.interval()를 사용해 일반화 오차의 95% 신뢰 구간을 계산할 수 있습니다.

 

from scipy import stats

confidence = 0.95
squared_errors = (final_predictions - y_test) ** 2
np.sqrt(stats.t.interval(confidence, len(squared_errors) - 1,
                        loc = squared_errors.mean(),
                        scale=stats.sem(squared_errors)))

 

 

우리의 결과가 95% 신뢰 구간에 속하는군요. 꽤 믿을 만한 결과였습니다. 이렇게, 마지막 단계인 테스트까지 진행해보았습니다.