Post

[혼자 공부하는 ML + DL] 3. 회귀 알고리즘과 모델 규제

아래 내용은 “혼자 공부하는 머신러닝+딥러닝” 책을 공부하며 정리한 내용입니다.

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

1. k-최근접 이웃 회귀

  • k-최근접 이웃 분류 : 예측하려는 샘플 중, 가장 가까운 k개 선택 → 가장 많은 클래스를 예측 샘플의 클래스로 지정

  • k-최근접 이웃 회귀 : 예측하려는 샘플 중, 가장 가까운 k개 선택 → k개의 평균울 예측 샘플의 값으로 지정

데이터 준비

k-최근접 이웃 알고리즘을 회귀로 사용해서, 길이를 특성으로 무게를 타겟으로 두어 예측해보자.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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])

농어 데이터가 어떤 형태를 띠고 있는지 산점도로 그려보자. 길이 특성만 사용하기 때문에 x축에 두고 타겟인 무게는 y축에 놓겠다. 아래 그림을 보면 농어의 길이가 커질수록 무게도 늘어나는 것을 알 수 있다.

1
2
3
4
plt.scatter(perch_length, perch_weight)
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

농어 데이터 산점도농어 데이터 산점도

2장에서 모델링을 한 과정대로 모델에 사용하기 전 훈련 셋과 테스트 셋으로 나눠보자.

1
2
3
4
5
6
from sklearn.model_selection import train_test_split

train_input, test_input, train_target, test_target = train_test_split(perch_length, perch_weight, random_state=42)

print(train_input.shape)
(42, )

데이터셋을 나눈 후 배열의 형태를 확인해보니 튜플 형태 (42, )로 42개의 데이터가 1차원 리스트 형태이다. 사이킷런에서는 훈련 셋의 형태가 2차원이어야 하기 때문에 reshape() 메서드를 사용하여 임의로 바꿔주자. 넘파이는 배열의 크기를 자동으로 지정하는 기능도 제공하는데, 크기에 -1을 지정하면 나머지 원소 개수로 모두 채우라는 의미이다. reshape(-1, 1)을 사용하면 전체 원소 개수를 매번 외우지 않아도 되므로 편리하다.

1
2
3
4
5
train_input = train_input.reshape(-1, 1)
test_input = test_input.reshape(-1, 1)

print(train_input.shape, test_input.shape)
 (42,1) (14, 1)

결정계수(R²)

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

학습시켜보자.

1
2
3
4
5
6
7
from sklearn.neighbors import KNeighborsRegressor

knr = KNeighborsRegressor()
knr.fit(train_input, train_target)

print(knr.score(test_input, test_target))
 0.992809406101064

0.99로 엄청 좋은 점수가 나왔습다. 이 점수는 무엇을 의미할까?

분류의 경우 테스트 세트에 있는 샘플을 정확하게 분류한 개수의 비율이었다. 다른말로 정확도라고 했다. 하지만 회귀에서는 정확한 숫자를 맞힌다는 것은 거의 불가능하기 때문에 다른 값으로 평가하는데 이 점수를 결정계수(coefficient of determination)라고 부른다.

결정계수결정계수

만약 모델이 타겟의 평균 정도를 예측하는 수준이라면 분자와 분모가 비슷해 R²은 0에 가까워지게되고, 예측이 타겟에 아주 가까워지면 분자가 0에 가까워지기 때문에 1에 가까운 값이 된다.

그렇다면 0.99의 값은 아주 좋은값이다. 이는 직감적으로 얼마나 좋은지 이해하기가 어렵기 때문에 타겟과 예측한 값 사이의 차이를 구해서 어느 정도 예측이 벗어났는지 가늠해보자.

1
2
3
4
5
6
7
8
9
from sklearn.metrics import mean_absolute_error

# 테스트 셋에 대한 예측
test_prediction = knr.predict(test_input)

# 테스트 셋에 대한 평균 절대값 오차
mae = mean_absolute_error(test_target, test_prediction)
mae
 19.157142857142862

사이킷런은 sklearn.metrics 패키지 아래 여러 가지 측정 도구를 제공한다. 이 중에서 mean_absolute_error는 타겟과 예측의 절댓값 오차를 평균하여 반환한다.

따라서 결과에서 평균적으로 예측이 19g 정도 타겟값과 다르다는 것을 알 수 있다.

과대적합 vs 과소적합

보통 모델을 훈련셋으로 학습하면 훈련셋에 잘 맞는 모델이 만들어진다. 만약 훈련셋에서 평가 점수가 좋았는데 테스트셋에서 점수가 굉장히 나쁘다면 훈련 세트에 과대적합(overfitting) 되었다고 말한다. 즉, 훈련셋에만 잘 맞는 모델이랑 테스트셋과 나중에 실전에 투입할 경우 잘 동작하지 않을 것이다.

반대로 훈련셋보다 테스트셋의 점수가 더 높거나 두 점수가 모두 너무 낮은 경우 모델이 훈련셋에 과소적합(underfitting)되었다고 말한다. 즉, 모델이 너무 단순해서 훈련셋에 적절히 훈련되지 않은 경우이다.

1
2
3
print(knr.score(train_input, train_target))

 0.9698823289099254

앞선 모델은 훈련셋(0.97)보다 테스트셋(0.99)의 점수가 높으니 과소적합 모델이다. 이런 경우 모델을 조금 더 복잡하게 만들어 해결할 수 있다.

k-최근접 이웃 알고리즘으로 모델을 더 복잡하게 만드는 방법은 이웃의 개수 k를 줄이는 것이다.

1
2
3
4
5
6
7
8
9
10
11
# 이웃의 개수를 3으로 설정
knr.n_neighbors = 3

# 모델을 다시 학습
knr.fit(train_input, train_target)
print(knr.score(train_input, train_target))
 0.9804899950518966

# 테스트 세트 점수 확인
print(knr.score(test_input, test_target))
 0.9746459963987609

테스트셋의 점수가 훈련셋의 점수보다 낮아졌으므로 과소적합 문제를 해결한 것 같다. 또한 두 점수의 차이가 크지 않으므로 모델이 과대적합 된 것 같지도 않다.

2. 선형회귀

k-최근접 이웃의 한계

이 알고리즘은 가까운 k개의 샘플들의 평균값으로 예측한다. 따라서 새로운 샘플이 훈련 세트의 범위를 벗어나면 엉뚱한 값을 예측할 수 있다.

널리 사용되는 대표적인 선형 회귀 알고리즘을 사용해보자.

1
2
3
4
5
6
7
8
9
10
from sklearn.linear_model import LinearRegression

lr = LinearRegression()

# 선형 회귀 모델을 훈련 
lr.fit(train_input, train_target)

# 50cm 농어에 대해 예측
print(lr.predict([[50]]))
 [1241.83860323]

k-최근접 이웃 회귀를 사용했을 때와는 달리 선형 회귀는 50cm 농어의 무게를 아주 높게 예측했다. 선형 회귀의 모형은 y = ax + b로 LinearRegression 클래스는 데이터와 가장 잘 맞는 a 와 b를 찾는다.

a는 계수(coefficient) 또는 가중치(weight)라고 부르며, a 또는 b와 같이 머신러닝 알고리즘이 찾은 값을 모델 파라미터(model parameter)라고 부른다.

1
2
3
# LinearRegression이 찾은 a와 b 값
print(lr.coef_, lr.intercept_)
 [39.01714496] -709.0186449535477

훈련셋 샘플 산점도와 모델의 파라미터로 계산된 선형 직선을 그려보자.

1
2
3
4
5
6
7
8
9
10
11
# 훈련 셋의 산점도
plt.scatter(train_input, train_target)

# 15에서 50까지 1차 방정식 그래프
plt.plot([15, 50], [15*lr.coef_+lr.intercept_, 50*lr.coef_+lr.intercept_])

# 50cm 농어 데이터
plt.scatter(50, 1241.8, marker='^')
plt.xlabel('length')
plt.ylabel('weight')
plt.show()

훈련셋 산점도와 회귀선훈련셋 산점도와 회귀선

바로 이 직선이 선형 회귀 알고리즘이 찾은 최적의 직선이다. 훈련셋과 테스트셋에 대한 R² 점수를 확인해보자.

1
2
3
4
5
print(lr.score(train_input, train_target))
 0.939846333997604

print(lr.score(test_input, test_target))
 0.8247503123313558

꽤 높은 점수를 보이고 있는데 이상한 점이 있다. 선형 회귀 선은 쭉 뻗은 직선으로 농어 무게가 0g 이하로 예측할 가능성이 있다. 그림2 산점도를 자세히 보면 왼쪽 위로 조금 구부러진 곡선에 가까운 모습이다.

만약 최적의 곡선을 찾는다면 길이를 제곱한 2차 방정식으로 표현할 수 있을 것이다.

다항 회귀

1
2
3
4
5
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개로 늘어났다. 이 데이터로 모델에 학습을 시켜보자.

1
2
3
4
5
lr = LinearRegression()
lr.fit(train_poly, train_target)

print(lr.predict([[50**2, 50]]))
 [1573.98423528]

앞선 모델보다 더 높은 값을 예측했다.

2차 방정식은 선형이 아닌 비선형이라고 볼 수 있지만 길이의 제곱을 다른 변수로 치환하게 되면 선형 관계로 표현할 수 있게 되는데 이런 방정식을 다항식(polynomial)이라 부르며 다항회귀(polynomial regression)이라 부른다.

각 세트별로 R² 점수를 평가하면 훈련셋과 테스트셋에 대한 점수가 크게 높아졌지만 테스트셋의 평가 점수가 조금 더 높은 것으로 보아 과소적합이 조금 남아있는 것을 확인할 수 있다.

1
2
3
4
5
print(lr.score(train_poly, train_target))
 0.9706807451768623

print(lr.score(test_poly, test_target))
 0.9775935108325121

3. 특성 공학과 규제

다중 회귀

지금까지 농어의 길이 특성만 사용하여 선형 회귀 모델을 훈련시켰다.

이번엔 여러 개의 특성을 사용한 선형 회귀인 다중 회귀(Multiple regression)를 사용해보자.

공농어의 길이 뿐만 아니라 높이, 두께 특성 등을 사용해서 각 특성을 제곱한 값도 추가해보고 길이X높이 등과 같이 새로운 특성도 만들어서 사용해보겠다.

이렇게 기존의 특성을 사용해 새로운 특성을 뽑아내는 작업을 특성공학(feature engineering) 이라고 부른다.

먼저 3개의 특성 값과 무게값을 가져와보자.

데이터 준비

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# 특성 공학과 규제
import pandas as pd

# 농어 특성
df = pd.read_csv('https://bit.ly/perch_csv_data')
perch_full = df.to_numpy()
print(perch_full)
 [[ 8.4   2.11  1.41]
   [13.7   3.53  2.  ]
   ...
   [43.5  12.6   8.14]
   [44.   12.49  7.6 ]]
   
  # 농어 무게
  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])

특성과 무게 데이터를 훈련셋과 테스트셋으로 나누겠다. 그리고 사이킷런에서 제공하는 변환기(transformer)를 사용하여 feature engineering을 해보자.

사이킷런의 변환기

1
2
3
4
5
6
7
8
9
10
11
12
13
from sklearn.model_selection import train_test_split

train_input, test_input, train_target, test_target = train_test_split(perch_full, perch_weight, random_state = 42)

# feature engineering
from sklearn.preprocessing import PolynomialFeatures

poly = PolynomialFeatures(include_bias=False) # 회귀식의 절편값 제거
poly.fit(train_input)
train_poly = poly.transform(train_input)

print(train_poly.shape)
 (42, 9)

기존에 3개였던 특성이 9개로 늘어났다. 각 특성이 어떻게 만들어졌는지 get_feature_names() 메서드를 호출해보고 테스트 셋에도 적용해보겠다.

1
2
3
4
poly.get_feature_names()
 ['x0', 'x1', 'x2', 'x0^2', 'x0 x1', 'x0 x2', 'x1^2', 'x1 x2', 'x2^2']

test_poly = poly.transform(test_input)

여러 개의 특성을 사용하여 선형 회귀를 수행하고 평가해보자.

다중 회귀 모델 훈련

1
2
3
4
5
6
7
8
9
10
11
from sklearn.linear_model import LinearRegression

lr = LinearRegression()
lr.fit(train_poly, train_target)
# 학습셋 평가
print(lr.score(train_poly, train_target))
 0.9903183436982125

# 테스트셋 평가
print(lr.score(test_poly, test_target))
 0.9714559911594155

학습셋 평가 결과 0.99로 아주 높은 점수가 나왔다. 농어의 길이뿐만 아니라 높이와 두께를 모두 사용했고 각 특성을 제곱하거나 서로 곱해서 다항 특성을 더 추가했더니 선형 회귀의 능력이 매우 강하는 것을 알 수 있었다. 테스트셋의 점수는 높아지지 않았지만 농어의 길이만 사용했을 때 있던 과소적합 문제는 더 이상 나타나지 않았다.

그럼 특성을 더 많이 추가하면 어떻게 될까? PolynomialFeatures클래스의 degree 매개변수를 사용하여 필요한 고차항의 최대 차수를 지정할 수 있다. 5제곱까지 특성을 만들어 출력해보자.

1
2
3
4
5
6
7
poly = PolynomialFeatures(degree = 5, include_bias=False)
poly.fit(train_input)
train_poly = poly.transform(train_input)
test_poly = poly.transform(test_input)

print(train_poly.shape)
 (42, 55)

특성 개수가 무려 55개나 생겼다. 다시 학습해보자.

1
2
3
4
5
6
7
8
9
lr = LinearRegression()
lr.fit(train_poly, train_target)
# 학습셋 평가
print(lr.score(train_poly, train_target))
 0.9999999999938143

# 테스트셋 평가
print(lr.score(test_poly, test_target))
 -144.40744532797535

학습셋에는 0.99로 동일하게 높은 점수를 보이나 테스트셋은 -144로 아주 낮은 수치를 보인다.

왜그럴까?

특성의 개수가 많아지면 선형 모델은 학습셋에 거의 완벽하게 학습을 하는 과대적합이 되어 테스트셋에는 맞지 않는 모델이 된다. 이런 현상을 줄이기 위해 규제(regularization)를 해보도록 하겠다. 선형 회귀 모델의 경우는 특성에 곱해지는 계수의 크기를 작게 만드는 일을 한다.

규제

규제규제

규제를 통해 훈련셋의 점수를 낮추고 테스트셋의 점수를 높여보겠다. 그 전에 앞에 배운 특성의 스케일을 사이킷런의 StandardScaler 클래스를 사용하여 정규화하자.

1
2
3
4
5
6
from sklearn.preprocessing import StandardScaler

ss = StandardScaler()
ss.fit(train_poly)
train_scaled = ss.transform(train_poly)
test_scaled = ss.transform(test_poly)

선형 회귀 모델에 규제를 추가한 모델을 릿지(ridge)와 라쏘(lasso)라고 부른다. 릿지는 계수를 제곱한 값을 기준으로 규제를 적용하고, 라쏘는 계수의 절댓값을 기준으로 규제를 적용한다.

일반적으로는 릿지를 조금 더 선호하며 두 알고리즘 모두 계수의 크기를 줄이지만 라쏘는 아예 0으로 만들수도 있다. 릿지와 라쏘를 이용하여 모델을 학습해보자.

릿지 회귀

1
2
3
4
5
6
7
8
9
# 릿지 회귀 학습
from sklearn. linear_model import Ridge
ridge = Ridge()
ridge.fit(train_scaled, train_target)
print(ridge.score(train_scaled, train_target))
 0.9896101671037343

print(ridge.score(test_scaled, test_target))
 0.9790693977615386

학습셋의 평가 점수는 조금 낮아졌지만 테스트셋의 평가 점수는 0.97로 정상으로 돌아왔다. 많은 특성을 사용했음에도 불구하고 과대적합되지 않아 테스트세트에서도 좋은 성능을 내고 있다.

릿지와 라쏘 모델을 사용할 때 규제의 양을 alpha 매개변수로 규제의 강도를 조절할 수 있다.
alpha 값이 크면 규제 강도가 세지므로 계수 값을 더 줄이고 더 과소적합이 되도록 유도한다. alpha 값이 작으면 계수를 줄이는 역할이 줄어들고 선형 회귀 모델과 유사해지므로 과대적할될 가능성이 커진다.

alpha값은 학습에 의해 정해지는 값이 아니라 사람이 지정해야하는 값으로 이런 파라미터를 하이퍼파라미터(hyperparameter)라고 부른다. 적절한 alpha값을 찾는 한 가지 방법은 R² 값의 그래프를 그려봐서 훈련셋과 테스트셋의 점수가 가장 가까운 지점을 찾는 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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()

릿지회귀릿지 회귀

alpha값을 0.001에서 100까지 10배씩 늘려가면서 릿지 회귀 모델을 훈련한 다음 훈련셋과 테스트셋의 점수를 리스트에 저장하여 그래프로 그렸다. np.log10() 함수를 사용한 이유는 0.001값이 너무 작기 때문에 왼쪽으로 너무 촘촘해져서 6개의 값을 동일한 간격으로 나타내기 위해 지수로 표현하였다.

파란색은 훈련셋, 노란색은 테스트셋으로 왼쪽은 점수 차이가 아주 많이 크고, 과대적합의 전형적인 모습을 볼 수 있다. 오른쪽은 훈련셋과 테스트셋이 모두 낮아지는 과소적합으로 가는 모습을 보이고 있다.

적절한 alpha값으 두 그래프가 가장 가깝고 테스트셋의 점수가 가장 높은 지수 값은 -1 이므로 aplha값을 0.1로 하여 최종 모델을 훈련해보자.

1
2
3
4
5
6
7
ridge = Ridge(alpha = 0.1)
ridge.fit(train_scaled, train_target)
print(ridge.score(train_scaled, train_target))
 0.9903815817570368

print(ridge.score(test_scaled, test_target))
 0.9827976465386896

훈련셋과 테스트셋의 점수가 비슷하게 모두 높고 과대적합과 과소적합 사이에서 균형을 맞추고 있다!

라쏘 회귀

위와 동일하게 라쏘 모델이 alpha값에 따라 어떻게 변하는지 그래프로 확인해보자. 릿지 모델과 다르게 라쏘 모델에 max_iter 매개변수가 추가 되었는데 이는 반복횟수가 부족할때 경고메세지가 나와서 반복횟수를 충분히 늘려주기 위해 사용한 것이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 라쏘 회귀
from sklearn.linear_model import Lasso

train_score = []
test_score = []

alpha_list = [0.001, 0.01, 0.1, 1, 10, 100]
for alpha in alpha_list:
    # 라쏘 회귀 학습
    lasso = Lasso(alpha = alpha, max_iter = 10000)
    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()

라쏘회귀라쏘 회귀

릿지모델보다 그래프가 더 이뻐 보이지만 왼쪽에선 과대적합이 오른쪽에서 과소적합이 되고 있는 모습리다. 라쏘 모델도 최적의 alpha값은 10이다. 라쏘는 의미 없는 계수값을 0으로 만드는 특징이 있다고 하였는데 얼마나 많은 feature들을 0으로 만들었는지 확인해보자.

1
2
print(np.sum(lasso.coef_==0))
 35

55개의 특성 중 35개를 0으로 만들어서 20개 특성만 사용하였다. 이런 특징 때문에 라쏘 모델은 유용한 특성을 골라내는 용도로 사용할 수도 있다.

This post is licensed under CC BY 4.0 by the author.