본문 바로가기

Major Study/25-1 Machine Learning

[Machine Learning] chapter3 - 회귀 알고리즘과 모델 규제

1. K-Nearest Neighbors Regression

지도 학습 알고리즘은 크게 분류와 회귀(regression)으로 나뉜다.

회귀는 클래스 중 하나로 분류하는 것이 아니라 임의의 숫자를 예측하는 문제로 정해진 클래스가 없고 수치를 출력한다.

 

k-최근접 이웃 회귀도 분류와 똑같이 예측하려는 샘플에 가장 가까운 k개를 선택한다.

하지만 회귀이기 때문에 샘플의 타깃은 클래스가 아니고 임의의 수치이다.

이웃 샘플의 수치를 사용해 x를 예측하려면 이 수치들의 평균을 구하면 된다.

 

데이터 준비

데이터를 준비하고 산점도를 그려보면,

import numpy as np
import matplotlib.pyplot as plt
perch_length = np.array([8.4, 13.7, 15.0, 16.2, 17.4, 18.0, 18.7, 19.0, 19.6, 20.0, 21.0,
       21.0, 21.0, 21.3, 22.0, 22.0, 22.0, 22.0, 22.0, 22.5, 22.5, 22.7,
       23.0, 23.5, 24.0, 24.0, 24.6, 25.0, 25.6, 26.5, 27.3, 27.5, 27.5,
       27.5, 28.0, 28.7, 30.0, 32.8, 34.5, 35.0, 36.5, 36.0, 37.0, 37.0,
       39.0, 39.0, 39.0, 40.0, 40.0, 40.0, 40.0, 42.0, 43.0, 43.0, 43.5,
       44.0])
perch_weight = np.array([5.9, 32.0, 40.0, 51.5, 70.0, 100.0, 78.0, 80.0, 85.0, 85.0, 110.0,
       115.0, 125.0, 130.0, 120.0, 120.0, 130.0, 135.0, 110.0, 130.0,
       150.0, 145.0, 150.0, 170.0, 225.0, 145.0, 188.0, 180.0, 197.0,
       218.0, 300.0, 260.0, 265.0, 250.0, 250.0, 300.0, 320.0, 514.0,
       556.0, 840.0, 685.0, 700.0, 700.0, 690.0, 900.0, 650.0, 820.0,
       850.0, 900.0, 1015.0, 820.0, 1100.0, 1000.0, 1100.0, 1000.0,
       1000.0])

plt.scatter(perch_length, perch_weight)
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

 

농어의 길이가 커짐에 따라 무게도 늘어나는 현상을 보인다.

이제 데이터를 훈련 세트와 테스트 세트로 나눈다.

import sklearn.model_selection import train_tests_split
train_input, test_input, train_target, test_target = train_test_split(perch_length, perch_weight, random_state=42)

 

사이킷런에 사용할 훈련 세트는 2차원 배열이어야 하기 때문에 .reshape()으로 배열의 크기를 바꿔준다.

train_input = train_input.reshape(-1,1)
test_input = test_input.reshape(-1,1)
print(train_input, test_input)
>>> (42,1) (14,1)

 

결정계수(R^2)

사이킷런에서 k-최근접 이웃 회귀 알고리즘을 구현한 클래스는 KNeighborsRegressor이다.

객체를 생성하고 fit() 모델로 회귀 모델을 훈련시킨다.

from sklearn.neighbors import KNeighborsRegressor

knr = KNeighborsRegression()
knr.fit(train_input, train_target)
print(knr.score(test_input, test_target))
>>> 0.992809406101064

 

위 score에서 나온 점수는 정답을 맞힌 개수의 비율이다.

회귀의 경우 조금 다른 값으로 평가는데 이 점수를 결정계수라고 한다.

결정계수는 다음처럼 계산된다.

 

예측값이 타깃에 아주 가까워지면 1이 된다.

from sklearn.metircs import mean_absolute_error

test_prediction = knr.predict(test_input)
mae = mean_absolute_error(test_target, test_prediction)
print(mae)
>>> 19.157142857142862

 

정확도처럼 회귀계수로 얼마나 좋은지 모르겠다면 sklearn.metircs 패키지에 여러 가지 측정 도구를 사용하면 좋다.

이 중에서 우리는 mean_absolute_error를 사용해서 타깃과 예측의 절댓값 오차를 평균해서 반환환다.

위 결과에서는 예측이 평균적으로 19g 정도 타깃값과 다르다고 볼 수 있다.

 

과대적합 vs 과소적합

 

훈련 세트와 테스트 세트의 R^2를 확인해보겠다.

print(knr.score(train_input, train_target))
>>> 0.9698823289099254
print(knr.score(test_input, test_target))
>>> 0.992809406101064

 

모델을 훈련 세트에 훈련하면 훈련 세트에 더 잘 맞는 모델이 만들어진다.

그럼 점수가 훈련 세트에서 좀 더 높게 나와야 하는데 이 모델은 테스트 세트에서 더 높게 나왔다.

 

이런 경우를 모델이 훈련 세트에 과소적합(underfitting)되었다고 말한다.

즉, 모델이 너무 단순해서 훈련 세트에 적절히 훈련되지 않은 것이다.

 

반대로 훈련 세트에서 점수가 좋았는데 테스트 세트에서 점수가 나쁜 경우는 과대적합(overfitting)되었다고 말한다.

 

과소적합 문제는 모델을 조금 더 복잡하게 만들면 해결이 된다.

즉, 훈련 세트에 더 잘 맞게 만들면 테스트 세트의 점수는 조금 낮아질 것이다.

 

k-최근접 이웃 알고리즘으로 모델을 더 복잡하게 만드려면 이웃의 개수 k를 줄인다.

이웃의 개수를 줄이면 훈련 세트에 있는 국지적인 패턴에 민감해지고, 이웃의 개수를 늘리면 데이터 전반에 있는 일반적인 패턴을 따른다.

knr.k_neighbors = 3
knr.fit(train_input, train_target)

print(knr.score(train_input, train_target))
print(knr.score(test_input, test_target))
>>> 0.9804899950518966
>>> 0.9746459963987609

 

k 값을 줄였더니 훈련 세트의 점수가 더 높아졌다.

테스트 세트 점수보다 훈련 세트 점수가 더 높고 둘 차이도 크지 않는 걸 보니 과소적합, 과대적합 모두 일어나지 않은 것 같다.

 

2. 선형 회귀

knr = KNeighborsRegressor(n_neighbors=3)
print(knr.predict([[50]])
>>> [1033.33333333]

 

50cm인 농어의 무게를 예측하니 1,033g 정도로 예측했다.

하지만 실제 이 농어의 무게는 훨씬 더 많이 나간다고 한다.

 

문제점을 파악하기 위해 훈련 세트와 50cm 농어와 이 농어의 최근접 이웃을 산점도에 표시해보겠다.

import matplotlib.pyplot as plt
distances, indexes = knr.kneighbors([[50]])
plt.scatter(train_input, train_target)
plt.scatter(train_input[indexes], train_target[indexes], marker='D')
plt.scatter(50,1033,marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

그림에서 50cm 농어와 가장 가까운 것은 45cm 근방이기 때문에 이 알고리즘은 45cm 샘플들의 무게를 평균한다.

이웃 샘플의 타깃의 평균을 구해보자.

print(np.mean(train_target[indexes]))
>>> 1033.3333333333333
print(knr.predict[[100]])
>>> [1033.33333333]

 

모델에서 예측한 값과 같다. 

심지어 길이가 100cm인 농어도 1,033g으로 예측한다.

따라서 새로운 샘플이 훈련 세트의 범위를 벗어나면 엉뚱한 값을 예측하는 걸 볼 수 있다.

k-최근접 이웃을 사용해 이 문제를 해결하려면 가장 큰 농어가 포함되도록 훈련 세트를 다시 만들어야 한다.

 

선형 회귀

선형 회귀 (linear regression)은 널리 사용되는 대표적인 회귀 알고리즘이다.

선형이란 말에서 짐작 가능하듯이, 특성이 하나인 경우 어떤 직선을 학습하는 알고리즘이다.

그 직선은 특성들을 가장 잘 나타낼 수 있는 직선으로 찾아야 한다.

from sklearn.linear_model import LinearRegression
lr = LinearRegression()
lr.fit(train_input,train_target)
print(lr.predict([[50]]))
>>> [1241.83860323]

 

k-최근접 이웃 회귀를 사용했을 때보다 50cm 농어의 무게를 높게 예측한 것을 볼 수 있다.

이 선형 회귀가 학습한 직선을 그려보고 어떻게 이런 값이 나온지 알아보자.

 

직선을 그리려면 기울기와 절편이 필요하다.

y = a * x + b처럼 쓸 수 있다. 여기에서 x를 농어 길이, y를 농어 무게로 바꾸면 다음과 같다.

print(lr.coef_, lr.intercept_)
>>> [39.01714496] -709.0186449535477

 

LinearRegression 클래스가 찾은 a와 b는 lr 객체의 coef_와 interception_ 속성에 저장되어 있다.

위 기울기와 절편을 사용해서 농어의 길이 15에서 50까지 직선으로 그려보자.

 

plt.scatter(train_input, train_target)
plt.plot([15,50], [15*lr.coef_+lr.intercept_, 50*lr.coef_+lr.intercept_])
plt.scatter(50,1241.8,marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

위 그림에 있는 직선이 선형 회귀 알고리즘이 이 데이터셋에서 찾은 최적의 직선이다.

 

print(lr.score(train_input, train_target))
print(lr.score(test_input, test_target))
>>> 0.939846333997604
>>> 0.8247503123313558

 

점수를 보면 훈련 세트와 테스트 세트가 차이가 많이 나고 훈련 세트의 점수도 높지 않다.

위 그래프의 왼쪽 아래를 보면 이상한 점이 있다.

길이가 15cm일 때, 무게가 0 아래로 직선이 그려져있다.

 

다항 회귀

농어의 길이와 무게에 대한 산점도를 자세히 보면 일직선이라기보다 구부러진 곡선에 가깝다.

그렇다면 최적의 직선보다 최적의 곡선을 찾는 것이 더 좋을 것이다.

2차 방정식의 그래프를 그리려면 길이를 제곱한 항이 훈련 세트에 추가되어야 한다.

train_poly = np.column_stack((train_input**2, train_input))
test_poly = np.column_stack((test_input**2, test_input))
print(train_poly.shape, test_poly.shape)
>>> (42, 2) (14, 2)

 

원래 특성인 길이를 제곱하여 왼쪽 열에 추가했기 때문에 모두 열이 2개로 늘어났다.

이 데이터로 다시 선형 회귀 모델을 훈련시키겠다.

여기서 주목할 점은 2차 방정식 그래프를 찾기 위해 훈련 세트에 제곱 항을 추가했지만 타깃값은 그대로 사용해야 한다.

무게를 예측할 때도 길이의 제곱과 원래 길이도 같이 넣어줘야 한다.

lr = LinearRegression()
lr.fit(train_poly, train_target)
print(lr.predict([[50**2,50]])
>>> [1573.98423528]
print(lr.coef_, lr.intercept_)
>>> [1.01433211 -21.55792498] 116.0502107827827

 

이 모델은 다음과 같은 그래프를 학습했다.

무게 = 1.01 x 길이^2 - 21.55 x 길이 + 116.05

이러한 방정식을 다항식이라 부르고 다항식을 사용한 선형 회귀를 다항 회귀라고 부른다.

 

point = np.arange(15,50)
plt.scatter(train_input, train_target)
plt.plot(point, 1.01*point**2 - 21.6*point + 116.05)
plt.scatter(50,1574, marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

 

print(lr.score(train_poly, train_target))
print(lr.score(test_poly, test_target))
>>> 0.9706807451768623
>>> 0.9775935108325122

 

훈련 세트, 테스트 세트 모두 점수가 크게 높아졌다.

하지만 여전히 테스트 점수가 좀 높은 것으로 보아 과소적합이 남아있다.

더 복잡한 모델이 필요할 것 같다.

 

3. 회귀 알고리즘과 모델 규제

다중회귀

여러 개의 특성을 사용한 선형 회귀를 다중 회귀라고 부른다.

이번 예제부터는 농어의 길이, 높이, 두께도 사용하겠다.

이전 절에서처럼 3개의 특성을 각각 제곱하여 추가하고 거기다가 각 특성을 서로 곱해서 다른 특성을 만들겠다.

이렇게 기존의 특성을 사용해서 새로운 특성을 뽑아내는 작업을 특성 공학이라고 한다.

import pandas as pd
import numpy as np

df = pd.read_csv('https://bit.ly/perch_csv_data')
perch_full = df.to_numpy()

perch_weight = np.array(
    [5.9, 32.0, 40.0, 51.5, 70.0, 100.0, 78.0, 80.0, 85.0, 85.0,
     110.0, 115.0, 125.0, 130.0, 120.0, 120.0, 130.0, 135.0, 110.0,
     130.0, 150.0, 145.0, 150.0, 170.0, 225.0, 145.0, 188.0, 180.0,
     197.0, 218.0, 300.0, 260.0, 265.0, 250.0, 250.0, 300.0, 320.0,
     514.0, 556.0, 840.0, 685.0, 700.0, 700.0, 690.0, 900.0, 650.0,
     820.0, 850.0, 900.0, 1015.0, 820.0, 1100.0, 1000.0, 1100.0,
     1000.0, 1000.0]
     )

 

그 다음 perch_full과 perch_weight를 훈련 세트와 테스트 세트로 나눈다.

from sklearn.model_selection import train_test_split
train_input, test_input, train_target, test_target = train_test_split(perxh_full, perch_weight, random_state=42)

 

사이킷런의 변환기

사이킷런은 특성을 만들거나 전처리하기 위한 클래스인 변환기를 제공한다.

from sklearn.preprocessing import PolynomialFeatures
poly = PolynomialFeatures(include_bias=False)
ploy.fit([[2,3]])
print(poly.transform([[2,3]]))
>>> array([[2., 3., 4., 6., 9.]])

 

선형 방정식의 절편은 항상 1인 특성과 곱해주는 계수이다.

1이 추가가 되면 특성은 (길이, 높이, 두께, 1)이 된다.

하지만 사이킷런의 선형 모델은 자동으로 절편을 추가하기 때문에 1 특성을 만들 필요가 없다.

그래서 위 특성에서도 1이 추가가 되어야 하지만 include_bias를 False로 입력해서 만들지 않았다.

poly = PolynomialFeatures(include_bias=False)
poly.fit(train_input)
train_poly = poly.transform(train_input)
print(train_poly.shape)
>>> (42, 9)

poly.get_feature_names_out()
>>> array(['x0', 'x1', 'x2', 'x0^2', 'x0 x1', 'x0 x2', 'x1^2', 'x1 x2', 'x2^2'], dtype=object)
       
test_poly = poly.transform(test_input)

 

get_feature_names_out() 메서드를 호출하면 9개의 특성이 각각 어떤 입력의 조합으로 만들어졌는지 알려준다.

 

다중 회귀 모델 훈련하기

from sklearn.linear_model import LinearRegression
lr = LinearRegression()
lr.fit(train_poly, train_target)
print(lr.score(train_poly, train_target))
print(lr.score(test_poly, test_target))
>>> 0.9903183436982125
>>> 0.9714559911594111

 

특성을 더 많이 추가해서 훈련을 시켜보겠다.

degree 매개변수를 사용해서 고차항의 최대 차수를 지정할 수 있다.

 

poly = polynomialFeatures(degree=5, include_bias=False)
poly.fit(train_input)
train_poly = poly.transform(train_input)
test_poly = poly.transform(test_poly)
print(train_poly.shape)
>>> (42, 55)

lr.fit(train_poly, train_target)
print(lr.score(train_poly, train_target))
print(lr.score(test_poly, test_target))
>>> 0.9999999999996433
>>> -144.40579436844948

 

훈련세트에 지나치게 과대적합 되어서 테스트 세트에는 사용할 수 없다.

 

규제

규제란 모델이 훈련 세트를 너무 과도하게 학습하지 못하게 방해하는 것이다.

즉, 모델이 훈련 세트에 과대적합 되지 않게 만드는 것이다.

선형 회귀 모델의 경우에는 특성에 곱해지는 계수의 크기를 작게 만든다.

from sklearn.preprocessing import StandardScaler
ss = StandardScaler()
ss.fit(train_poly)
train_scaled = ss.transform(train_poly)
test_scaled = ss.transform(test_poly)

 

특성의 스케일이 정규화되지 않으면 당연하게 특성에 곱해지는 계수 값도 달라지기 때문에 정규화를 해줘야 한다.

여기서는 StandardScaler를 사용하겠다.

 

릿지 회귀

앞서 준비한 train_scaled 데이터로 릿지 모델을 훈련해보겠다.

from sklearn.linear_model import Ridge
ridge = Ridge()
ridge.fit(train_scaled, train_target)
print(ridge.score(train_scaled, train_target))
print(ridge.score(test_scaled, test_target))
>>> 0.9896101671037343
>>> 0.9790693977615387

 

릿지에는 규제의 강조를 조절하는 alpha 매개변수가 있다.

값이 커질수록 규제의 강도가 세져서 계수 값을 줄이고 과소적합 되도록 유도한다.

그렇다면 적절한 alpha 값을 어떻게 찾아야 할까?

alpha 값을 찾는 한 가지 방법은 alpha 값에 대한 R^2 값의 그래프를 그려보는 것이다.

 

import matplotlib.pyplot as plt
train_score = []
test_score = []

alpha_list = [0.001, 0.01, 0.1, 1, 10, 100]
for alpha in alpha_list :
	ridge = Ridge(alpha=alpha)
    ridge.fit(train_scaled, train_target)
    train_score.append(ridge.score(train_scaled, train_target))
    test_score.append(ridge.score(test_scaled, test_target))
    
plt.plot(np.log10(alpha_list), train_score)
plt.plot(np.log10(alpha_list), test_score)
plt.xlabel('alpha')
plt.ylabel('R^2')
plt.show()

ridge = Ridge(alpha=0.1)
ridge.fit(train_scaled, train_target)
print(ridge.score(train_scaled, train_target))
print(ridge.score(test_scaled, test_target))
>>> 0.9903815817570367
>>> 0.9827976465386928

 

제일 좋은 alpha 값은 0.1이라는 것을 알 수 있다.

 

라쏘 회귀

from sklearn.linear_model import Lasso
lasso = Lasso()
lasso.fit(train_scaled, train_target)
print(lasso.score(train_scaled, train_target))
print(lasso.score(test_scaled, test_target))
>>> 0.989789897208096
>>> 0.9800593698421883
import matplotlib.pyplot as plt
train_score = []
test_score = []

alpha_list = [0.001, 0.01, 0.1, 1, 10, 100]
for alpha in alpha_list :
	lasso = Lasso(alpha=alpha)
    lasso.fit(train_scaled, train_target)
    train_score.append(lasso.score(train_scaled, train_target))
    test_score.append(lasso.score(test_scaled, test_target))
    
plt.plot(np.log10(alpha_list), train_score)
plt.plot(np.log10(alpha_list), test_score)
plt.xlabel('alpha')
plt.ylabel('R^2')
plt.show()

lasso = Lasso(alpha=10)
lasso.fit(train_scaled, train_target)
print(lasso.score(train_scaled, train_target))
print(lasso.score(test_scaled, test_target))
print(np.sum(lasso.coef_ == 0))
>>> 40

 

우리는 55개의 특성을 사용했지만 라쏘모델이 사용한 특성은 15개뿐이다.