- Published on
NumPy로 릿지 회귀 밑바닥부터 구현하기
- Authors

- Name
- 이동영
- Github
- @Github
TensorTonic의 Ridge Regression 문제를 NumPy만 사용해서 경사하강법으로 풀면서 정리했다. 선형 회귀에 L2 정규화 항 하나가 추가됐을 뿐이지만 "왜 weight를 작게 유지해야 하는가"라는 질문이 기저에 있어 그 지점을 중심으로 다룬다.
릿지 회귀 모델
릿지 회귀(Ridge Regression)는 선형 회귀에 L2 정규화(L2 regularization)를 더한 모델이다. 예측 식은 선형 회귀와 완전히 동일하다.
바뀌는 것은 손실 함수다. 기존 MSE에 weight 크기에 대한 페널티 항이 붙는다.
- 앞항: 선형 회귀와 동일한 MSE
- 뒷항: (weight 제곱합)
- : 정규화 강도 (이면 일반 선형 회귀와 동치)
중요한 점은 bias 는 페널티에 포함되지 않는다는 것이다. 이유는 뒤에서 따로 다룬다.
왜 weight를 작게 만들려고 하는가
선형 회귀만으로도 MSE는 잘 줄어드는데 굳이 weight 크기까지 건드리는 이유는 과적합(overfitting) 방지다. 직관을 잡기 위해 weight가 커지면 어떤 일이 벌어지는지 먼저 본다.
큰 weight = 입력에 민감한 모델
예측식 에서 weight는 "입력 한 단위가 출력에 미치는 영향"이다. weight가 크면 입력의 작은 변화가 출력에서 크게 증폭된다.
같은 feature에 대해 두 모델을 비교해 보자.
| weight | 입력 변화 | 출력 변화 |
|---|---|---|
| 10 | 3 → 3.1 | 30 → 31 |
| 10,000 | 3 → 3.1 | 30,000 → 31,000 |
학습 데이터에는 측정 오차나 노이즈가 섞여 있는데 weight가 크면 이 노이즈까지 증폭된다. 모델이 학습 데이터의 **신호(signal)**만 학습하는 게 아니라 노이즈까지 외워버리는 상태로 가기 쉽다.
과적합의 징후
feature가 많을 때 MSE만 최소화하는 모델은 종종 이런 weight를 찾아낸다.
큰 양수와 큰 음수가 복잡하게 상쇄되면서 학습 데이터를 완벽히 맞춘다. 하지만 새 데이터가 들어오면 이 상쇄가 깨지고 예측이 엉뚱한 방향으로 튄다. 학습 점수는 100점인데 실전 점수는 바닥인 전형적인 과적합 시나리오다.
릿지의 페널티 항 는 이런 폭주를 막는 브레이크다. "weight가 커지면 loss도 같이 커진다"는 강제력을 넣어서 꼭 필요한 만큼만 weight를 키우게 한다.
"정답을 덜 맞추는" 설계
여기서 자연스럽게 드는 의문이 하나 있다. weight에 제약을 걸면 학습 데이터에 대한 MSE는 당연히 커질 텐데 그건 오히려 나빠지는 것 아닌가?
맞는 지적이다. 학습 데이터에 대해서는 릿지가 항상 더 못 맞춘다. 하지만 우리가 진짜 원하는 건 학습 데이터가 아니라 새 데이터에서의 성능이다.
| 학습 데이터 | 새 데이터 | |
|---|---|---|
| MSE () | 100점 | 60점 |
| 릿지 (적절한 ) | 95점 | 85점 |
| 릿지 ( 너무 큼) | 70점 | 65점 |
릿지는 학습 점수를 의도적으로 깎는 대신 새 데이터 점수를 올리는 트레이드오프다. 가 너무 작으면 선형 회귀와 차이가 없고 너무 크면 아예 학습을 못 해서 양쪽 다 망친다. 적절한 는 보통 교차검증(cross-validation)으로 찾는다.
이 구조는 **편향-분산 트레이드오프(bias-variance tradeoff)**로 공식화된다.
- 편향(Bias): 모델이 너무 단순해서 패턴을 놓치는 정도.
- 분산(Variance): 학습 데이터가 바뀔 때 모델이 얼마나 흔들리는지.
MSE만 쓰면 편향은 낮고 분산이 높다. 릿지는 편향을 약간 올리는 대신 분산을 크게 낮춰서 총 오차를 줄인다.
페널티는 0이 될 수 없다
손실 함수를 다시 보자.
페널티 항 가 0이 되려면 이어야 한다. 그런데 이면 모델은 입력을 무시하고 만 뱉는 상수 함수가 되어버린다. 당연히 MSE가 엄청 커진다.
즉 쓸만한 모델에서 페널티는 항상 양수다. 최적화 과정은 "MSE를 0으로 만드는 지점"이 아니라 "MSE 감소분과 페널티 증가분이 균형을 이루는 지점"을 찾는다. 페널티를 완전히 제거할 수 없다는 것은 버그가 아니라 의도된 설계다. weight에 언제나 비용이 따라붙어야 과적합 방향으로 달아나지 않는다.
L1 / L2 norm 짚고 가기
"L2 정규화"라는 이름이 붙은 이유를 잠깐 정리한다.
Lp space에서 온 이름
**L은 Lebesgue(르베그)**라는 프랑스 수학자 이름에서 왔다. 20세기 초 르베그가 측도론(measure theory)에서 정의한 함수 공간을 space라고 부르는데 각 공간마다 고유한 norm(벡터의 크기를 재는 방법)이 있다. 일반식은 다음과 같다.
에 값을 대입하면 우리가 아는 norm들이 나온다.
- : 절댓값의 합 (L1 norm, Manhattan distance)
- : 제곱합의 루트 (L2 norm, Euclidean distance)
- : 최댓값 (L∞ norm, Chebyshev distance)
머신러닝에서 쓰는 L1, L2는 이 norm을 weight 벡터에 적용한 것이다.
L1 vs L2: 어떻게 다른가
같은 weight에 대해 두 norm이 매기는 "가격"이 다르다.
| weight | L1 (절댓값) | L2 (제곱) |
|---|---|---|
| 10 | 10 | 100 |
| 1 | 1 | 1 |
| 0.1 | 0.1 | 0.01 |
| 0.01 | 0.01 | 0.0001 |
- L2: 큰 weight는 제곱으로 엄벌하고 작은 weight는 거의 방치한다. → weight를 0 근처로 밀지만 정확히 0으로는 안 만든다.
- L1: 크기와 무관하게 일정 비율로 벌한다. → 중요하지 않은 weight를 아예 0으로 만든다 (sparse solution).
이 차이는 미분 형태에서 나온다. 의 도함수는 라서 가 0에 가까워지면 gradient도 약해져 0에 도달하지 못한다. 반면 의 도함수는 로 상수 크기라서 0까지 밀어붙인다.
- 선형 회귀 + L1 = Lasso
- 선형 회귀 + L2 = Ridge
- L1 + L2 섞기 = Elastic Net
Lasso와 Ridge라는 이름의 유래
두 모델이 나란히 등장하면서도 작명 방식은 꽤 다르다.
Ridge
**Hoerl & Kennard(1970)**의 논문 "Ridge Regression: Biased Estimation for Nonorthogonal Problems"에서 도입됐다. Hoerl이 이전부터 쓰던 ridge analysis라는 통계 기법에서 이름을 가져왔고 두 이미지가 겹쳐 있다.
- 대각선이 솟는 모양: 정규방정식에서 에 를 더하는 것은 대각선만 만큼 튀어나오게 하는 연산이다. 이 모양이 지형학의 능선(ridge)을 닮았다.
- Ridge trace: 를 0부터 키우며 각 weight가 어떻게 변하는지 그린 그래프를 Hoerl이 ridge trace라 불렀다. 곡선들이 산등성이를 따라가는 자취처럼 보인다.
Lasso
**Robert Tibshirani(1996)**의 논문 "Regression Shrinkage and Selection via the Lasso"에서 도입됐다. 이름은 두 겹의 의미를 동시에 담고 있다.
- 약어: Least Absolute Shrinkage and Selection Operator. L1의 핵심 효과(절댓값, 축소, feature 선택, 연산자)를 머리글자로 묶어 LASSO가 된다.
- 은유: lasso는 카우보이가 쓰는 올가미 밧줄이다. L1 제약이 weight 공간에 마름모 영역을 만들어 해를 꼭짓점(=0 좌표축)으로 끌어당기는 모습이 올가미가 weight를 낚아채는 그림과 맞아떨어진다.
학문적 정확성과 직관적 은유를 동시에 챙긴 작명이다.
Elastic Net
L1과 L2를 섞은 Elastic Net(Zou & Hastie, 2005)은 이름 자체가 "탄성 그물"이라는 뜻이다. Lasso의 올가미 은유를 더 넓게 감싸는 그물로 확장한 작명이다.
는 정확히 L2인가
엄밀히 말하면 L2 norm은 제곱합의 루트다.
그런데 릿지에서 쓰는 페널티는 루트 없는 제곱합이다.
이유는 미분 편의성이다. 이 있으면 미분 시 분모에 루트가 남아 지저분하고 에서 미분 불가능하다. 제곱 형태는 도함수가 로 깔끔하게 떨어진다.
최적화 결과는 둘이 사실상 동치다. 방향이 같고 만 적절히 조정하면 같은 해에 도달한다. 그래서 머신러닝에서는 제곱 버전을 쓰면서도 그냥 "L2 정규화"라고 부르는 관습이 자리잡았다.
기울기 유도
선형 회귀의 기울기 유도에 페널티 항만 추가로 미분하면 된다. 손실 함수를 두 부분으로 쪼개서 각각 미분한 뒤 합치는 전략이다.
에 대한 기울기
부분은 선형 회귀와 동일하다.
부분은 각 성분 의 미분이 이므로 를 곱해 벡터로 묶으면 다음과 같다.
합치면 문제에서 주어진 공식이 된다.
에 대한 기울기
페널티 항 에 가 없기 때문에 로 미분하면 0이다. 즉 bias에는 L2 정규화가 작용하지 않는다.
왜 bias는 규제하지 않나
bias는 출력 전체를 위아래로 평행 이동시키는 역할이다. 데이터의 평균이 10이면 bias가 10 근처에 있어야 하고 1000이면 1000 근처여야 한다. 여기에 "bias를 0에 가깝게"라는 강제를 걸면 모델이 데이터 중심을 제대로 못 맞추고 항상 편향된 예측을 내놓게 된다.
반면 weight는 "입력과 출력의 관계의 세기"라서 작아져도 모델이 여전히 정답 쪽을 가리킬 수 있다. 그래서 L2는 weight에만 적용하고 bias는 자유롭게 둔다. PyTorch, TensorFlow 같은 프레임워크의 weight_decay 옵션도 기본적으로 bias는 제외한다.
NumPy 구현
import numpy as np
def ridge_regression(X, y, lr, epochs, alpha):
"""
Perform ridge regression using gradient descent.
Returns: tuple of (weights_list, bias)
"""
X = np.asarray(X, dtype=float)
y = np.asarray(y, dtype=float)
n_samples, n_features = X.shape
w = np.zeros(n_features)
b = 0.0
for _ in range(epochs):
y_hat = X @ w + b
error = y_hat - y
dw = (2 / n_samples) * (X.T @ error) + 2 * alpha * w
db = (2 / n_samples) * np.sum(error)
w -= lr * dw
b -= lr * db
weights = [round(float(v), 4) for v in w]
bias = round(float(b), 4)
return weights, bias
선형 회귀 구현과 비교하면 dw 한 줄에 + 2 * alpha * w가 붙은 것이 유일한 차이다. db는 페널티의 영향을 받지 않으므로 그대로다.
해법이 동작하는 이유
위 구현을 한 번 더 정리하면 세 가지 관점으로 요약된다.
- L2 페널티가 붙은 경사하강법: 영 초기화된 weight와 bias에서 출발해 정규화된 MSE의 기울기로 업데이트한다. 표준 선형 회귀와의 유일한 차이는 weight 기울기에 가 더해진다는 점이고 이 항이 "큰 weight에 비용을 부과"해서 더 작고 일반화되기 쉬운 계수를 찾게 만든다.
- bias는 규제 대상이 아님: bias 기울기는 로 MSE 도함수만 있다. bias까지 규제하면 모델이 타깃의 평균을 맞출 자유를 잃어 과소적합(underfitting)으로 이어지기 때문이다.
- 의 역할: 이면 구현은 그대로 선형 회귀의 경사하강법과 동치다. 가 커질수록 weight가 0 쪽으로 수축하면서 학습 데이터 정확도와 일반화 성능 사이의 트레이드오프가 강해진다. 다중공선성(multicollinearity)이나 과적합 방지의 핵심 장치다.
weight decay와 동일한 구조
gradient 업데이트를 풀어 쓰면 L2 정규화의 효과가 눈에 잘 들어온다.
w -= lr * ((2 / n_samples) * (X.T @ error) + 2 * alpha * w)
이 식을 전개하면
w = w - lr * (MSE 기울기) - lr * 2 * alpha * w
= (1 - 2 * lr * alpha) * w - lr * (MSE 기울기)
매 epoch마다 weight에 라는 1보다 작은 상수가 곱해진다. 즉 MSE 기울기로 업데이트하기 전에 weight를 살짝 줄이고 시작하는 셈이다. 이 관점에서 L2 정규화를 weight decay라고도 부른다. 딥러닝 프레임워크의 옵티마이저에도 동일한 이름으로 노출돼 있다. 수학적으로 "L2 페널티를 loss에 더하는 것"과 "매 step마다 weight를 살짝 감쇠시키는 것"은 동치다.
반올림의 함정: np.round와 ULP
반환 부분은 처음에 다음처럼 작성했었다.
return np.round(w, 4).tolist(), round(float(b), 4)
수학적으로는 동일해 보이지만 채점기에서 테스트를 통과하지 못했다. 기대값과 마지막 비트 하나가 달랐다.
Expected: -1.6423073835038024e+16
Got: -1.6423073835038026e+16
왜 이런 차이가 나는지 이해하려면 먼저 float64의 해상도가 어떻게 구성되는지부터 봐야 한다.
ULP란 무엇인가
**ULP(Unit in the Last Place)**는 "어떤 float 값과 그 바로 다음으로 표현 가능한 float 값 사이의 거리"다. 즉 float로 표현할 수 있는 이웃 값과의 간격을 뜻한다.
IEEE 754 double precision(float64)은 64비트에 다음과 같이 숫자를 저장한다.
[ 부호 1비트 | 지수 11비트 | 가수(mantissa) 52비트 ]
어떤 실수 를 꼴로 표현한다. mantissa 앞에 붙는 암묵적인 선두 1비트를 포함하면 실효 정밀도는 53비트다.
mantissa 비트 수가 유한하므로 표현 가능한 값들이 실수 축 위에 이산적으로 찍혀 있고 지수 가 커질수록 점 사이 간격도 벌어진다. 값이 크면 ULP도 커진다.
| 스케일 | 지수 근사 | ULP 크기 |
|---|---|---|
| 0 | ||
| 20 | ||
| 50 | ||
| 53 | ||
| 66 |
Python에선 math.ulp(x)로 직접 확인할 수 있다.
>>> import math
>>> math.ulp(1.0)
2.220446049250313e-16
>>> math.ulp(1e16)
2.0
>>> math.ulp(1e20)
16384.0
릿지 출력이 스케일이라 이 영역에서 ULP가 정확히 2다. 기대값과 실제 출력이 2만큼 어긋났다는 건 "float64가 허용하는 가장 작은 이웃 값으로 빗나갔다"는 의미다.
경계와 정수 안전 영역
ULP 테이블에서 주목할 점은 근방부터 ULP가 정수 단위에 진입한다는 것이다. 이 경계가 이다.
- mantissa 실효 53비트(52비트 + 암묵적 선두 1비트)로 표현할 수 있는 최대 정수는 .
- 이 범위 안에서는 모든 정수를 float64로 정확히 표현 가능하다.
- 이 범위를 넘어서면 ULP가 이상이 되어 짝수만 표현되고 홀수는 가장 가까운 짝수로 반올림된다.
2^53 = 9007199254740992 # OK
2^53 + 1 = 9007199254740993 # 표현 불가, 9007199254740992로 반올림
2^53 + 2 = 9007199254740994 # OK
JavaScript의 Number.MAX_SAFE_INTEGER가 인 이유도 동일하다. 이 지점을 넘으면 "안전한 정수" 영역이 끝난다.
릿지 회귀가 발산해 weight가 스케일에 도달한 상황이 바로 이 경계 너머다. 여기서는 수학적으로 같은 식이라도 연산 순서가 달라지면 바로 ULP 단위 차이가 노출된다.
np.round가 부정확해지는 메커니즘
NumPy 문서는 np.around / np.round에 대해 "fast but sometimes inexact algorithm"이라고 명시한다. 구현은 대략 다음 패턴이다.
np.round(x, decimals) ≈ np.rint(x * 10**decimals) / 10**decimals
우리 상황에 대입해 보자.
x * 10^4 = (-1.6423e16) * 10000 ≈ -1.6423e20
이 값의 ULP는 수준이다. 곱셈 결과부터 이미 정수를 정확히 표현 못 하고 반올림된다. np.rint로 정수화한 뒤 다시 로 나누면 원본과 수 ULP 어긋난 값이 돌아온다.
반면 Python 내장 round(float(v), 4)는 완전히 다른 경로를 탄다. 내부적으로 dtoa(double-to-ASCII) 포매터를 써서 십진 표기에서 소수점 이하 자릿수만 처리한다. 곱셈/나눗셈을 경유하지 않으므로 스케일에서도 원본 float를 그대로 돌려준다. 에 소수점 이하가 없으면 반올림은 사실상 no-op이다.
weights = [round(float(v), 4) for v in w]
bias = round(float(b), 4)
return weights, bias
반올림 옵션 비교
같은 "4자리로 반올림" 동작도 구현에 따라 결과가 달라진다.
| 방식 | 대상 | 내부 동작 | 큰 값에서의 동작 | 속도 |
|---|---|---|---|---|
np.round(w, 4) / np.around(w, 4) | ndarray | rint(x * 10^d) / 10^d | ULP 오차 가능 | 매우 빠름 |
round(float(v), 4) | 스칼라 | dtoa 기반 십진 처리 | 원본 보존 | 보통 |
decimal.Decimal(v).quantize(...) | 스칼라 | 십진 고정소수점 연산 | 원본 보존, 가장 엄격 | 느림 |
f"{v:.4f}" 후 재파싱 | 스칼라 | 문자열 경유 | 원본 보존 | 가장 느림 |
np.around는np.round의 별칭이라 동일한 함정을 공유한다. 값 스케일이 이하면 벡터 속도 이점을 살려도 괜찮지만 그 이상에선 주의해야 한다.decimal.Decimal은 금융·회계처럼 십진수 정확성이 최우선인 도메인에서 쓴다. ML 학습 루프에선 보통 오버스펙이다.- 실무적으로는 "학습 루프 내부의 벡터 연산은
np.round로 빠르게, 제출/저장 직전의 최종 변환만 스칼라round로 재보장"하는 조합이 안전하다.
실무 메시지
np.round가 "틀렸다"는 건 아니다. 대부분의 경우 ULP 단위 오차는 관측조차 되지 않는다. 그러나
- 값이 경계 근처 또는 그 이상으로 커질 수 있는 상황
- 자동 채점기처럼 비트 단위 일치를 요구하는 환경
- 부동소수점 오차가 누적되어 디버깅이 어려워지는 긴 파이프라인
에서는 원소별 round(float(v), 4)가 안전한 선택이다. 업데이트 도중 numpy.float64로 바뀐 b도 float()로 감싸 파이썬 기본 타입에 맞춰야 isinstance(b, float) 같은 검사를 통과한다.
L2 정규화는 선형 회귀 전용이 아니다
릿지라는 이름은 선형 회귀와 L2의 조합만 가리키지만 L2 페널티 자체는 손실 함수와 독립적이다. 어떤 모델이든 뒤에 만 붙이면 된다.
gradient도 깔끔하게 분리된다.
모델별 적용 예시는 다음과 같다.
- 로지스틱 회귀 + L2:
scikit-learn의LogisticRegression은 기본값이 L2 정규화다(penalty='l2'). BCE loss 뒤에 를 붙이는 것뿐이다. - 신경망 + L2 (weight decay): PyTorch 옵티마이저의
weight_decay인자가 L2 계수 역할을 한다. 거의 모든 최신 모델(Transformer, ResNet 등)이 기본으로 사용한다. - SVM: 애초부터 L2가 내장돼 있다. SVM의 "margin 최대화"가 곧 최소화와 같다.
가까운 예로 앞선 로지스틱 회귀 글의 구현에도 L2 페널티 항 하나만 추가하면 바로 정규화된 분류기가 된다.
선형 / 로지스틱 / 릿지 비교
세 모델을 나란히 두면 경사하강법의 뼈대가 공유되고 손실 함수 설계만 달라진다는 것이 보인다.
| 선형 회귀 | 로지스틱 회귀 | 릿지 회귀 | |
|---|---|---|---|
| 출력 | |||
| 정답 타입 | 실수 | 0 또는 1 | 실수 |
| 손실 함수 | MSE | BCE | MSE + L2 페널티 |
릿지의 는 선형 회귀 에 를 더한 것뿐이고 는 동일하다. 로지스틱 회귀는 선형 모델 위에 시그모이드와 BCE를 함께 얹어 분류로 확장한 경우이고 릿지는 같은 선형 모델의 손실 함수에 L2 페널티 항 하나를 더한 경우다. "예측 → 오차 → 기울기 → 업데이트"라는 경사하강법의 뼈대는 모든 모델에서 동일하게 유지된다.
마무리
릿지 회귀 구현에서 특히 와닿은 지점은 다음과 같다.
- 학습은 MSE를 최소화하는 게 아니라 "일반화 오차"를 최소화하는 작업이다. 릿지는 학습 점수를 의도적으로 깎는 대신 새 데이터 점수를 올린다.
- L2 페널티는 weight에 영구적으로 부과되는 비용이다. 페널티가 0이 될 수 없다는 구조 자체가 과적합을 막는 힘이다.
- gradient 업데이트 관점에서 L2는 weight를 매 step 살짝 감쇠시키는 것(weight decay)과 동치다. 딥러닝의 표준 옵티마이저 옵션에 이 이름이 그대로 남아 있다.
- L2 페널티는 선형 회귀 전용이 아니다. 손실 함수가 무엇이든 뒤에 만 붙이면 된다.
선형 회귀와 로지스틱 회귀가 경사하강법의 "뼈대"를 잡았다면 릿지 회귀는 그 뼈대 위에 정규화라는 일반적인 도구를 얹는 사례다. 여기서 L1으로 바꾸면 Lasso가 되고 은닉층을 쌓고 weight decay를 그대로 적용하면 신경망 학습이 된다.