1편에서 np.linalg.lstsq가 내놓은 직선 ratio = 0.1936 * entropy - 0.6163이 무엇을 계산한 것인지는 건너뛰었다. 이 글에서는 손실을 미분해 정규방정식을 직접 풀고 lstsq와 같은 답이 나오는지 확인한 뒤, 설계행렬에 열을 추가해 오차를 절반으로 줄이고 그 과정에서 정규방정식이 깨지는 조건까지 실측한다.
데이터는 1편에서 만든 files.csv(파일 360개의 크기·바이트 엔트로피·gzip 크기)를 그대로 쓴다. 매번 읽어들이는 코드가 반복되므로 공용 모듈로 뺐다.
"""files.csv를 읽어 학습/평가 분할까지 만들어 두는 공용 모듈."""
import csv
import numpy as np
def load(path="files.csv", seed=0, train_ratio=0.7):
rows = list(csv.DictReader(open(path)))
ext = np.array([r["ext"] for r in rows])
size = np.array([int(r["size"]) for r in rows], dtype=float)
entropy = np.array([float(r["entropy"]) for r in rows])
ratio = np.array([int(r["gz_size"]) for r in rows]) / size
idx = np.random.default_rng(seed).permutation(len(rows))
n_train = int(len(rows) * train_ratio)
return ext, size, entropy, ratio, idx[:n_train], idx[n_train:]정규방정식을 직접 풀어보기
MSE L(θ) = ‖Xθ - y‖² / n은 θ에 대해 볼록한 이차식이라 기울기가 0인 지점이 곧 최소이고, 미분해 정리하면 정규방정식(normal equation) 한 줄이 남는다.
XᵀX θ = Xᵀy
1편의 무작위 탐색 20만 회가 못 따라잡은 해를 연립방정식 하나로 구해 lstsq와 비교한다.
#!/usr/bin/env python3
"""최소제곱을 정규방정식으로 직접 풀고 np.linalg.lstsq와 비교한다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
X = np.column_stack([entropy[tr], np.ones(len(tr))]) # 설계행렬 (n x 2)
y = ratio[tr]
# 정규방정식: (X^T X) theta = X^T y
XtX, Xty = X.T @ X, X.T @ y
theta_mine = np.linalg.solve(XtX, Xty)
theta_np = np.linalg.lstsq(X, y, rcond=None)[0]
print("X^T X =\n", XtX)
print(f"\n직접 푼 해 : w={theta_mine[0]:+.6f} b={theta_mine[1]:+.6f}")
print(f"lstsq의 해 : w={theta_np[0]:+.6f} b={theta_np[1]:+.6f}")
print(f"두 해의 최대 차이: {np.abs(theta_mine - theta_np).max():.2e}")
# 손실이 정말 최소인지 주변을 흔들어 확인
def mse(t):
return float(np.mean((X @ t - y) ** 2))
print(f"\nMSE(해) = {mse(theta_mine):.8f}")
for d in (0.01, -0.01):
print(f"MSE(w{d:+.2f}) = {mse(theta_mine + [d, 0]):.8f}")
print(f"MSE(b{d:+.2f}) = {mse(theta_mine + [0, d]):.8f}")$ python3 normal_eq.py
X^T X =
[[8635.52899301 1425.5121 ]
[1425.5121 251. ]]
직접 푼 해 : w=+0.193583 b=-0.616326
lstsq의 해 : w=+0.193583 b=-0.616326
두 해의 최대 차이: 2.89e-15
MSE(해) = 0.01593235
MSE(w+0.01) = 0.01937280
MSE(b+0.01) = 0.01603235
MSE(w-0.01) = 0.01937280
MSE(b-0.01) = 0.01603235
두 해의 차이가 2.89e-15로 부동소수점 오차 수준이다. 해 주변에서 w나 b를 0.01만 흔들어도 MSE가 올라가는 것으로 최소라는 것도 같이 확인된다.
설계행렬에 열을 추가한다
“선형”회귀의 선형은 파라미터에 대해 선형이라는 뜻이지, 특성이 직선이어야 한다는 뜻이 아니다. entropy²처럼 비선형 항을 열로 넣어도 푸는 방식은 똑같다.
#!/usr/bin/env python3
"""설계행렬에 열을 추가하면 같은 최소제곱이 어디까지 좋아지는지 비교한다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
logsize = np.log10(size)
FEATURES = {
"entropy": lambda: [entropy],
"entropy + log10(size)": lambda: [entropy, logsize],
"entropy + entropy^2": lambda: [entropy, entropy ** 2],
"entropy + entropy^2 + ^3": lambda: [entropy, entropy ** 2, entropy ** 3],
"위 3개 + log10(size)": lambda: [entropy, entropy ** 2, entropy ** 3, logsize],
}
def design(cols, rows):
return np.column_stack([c[rows] for c in cols] + [np.ones(len(rows))])
print(f"{'설계행렬 열 구성':<28}{'열 수':>5}{'학습 MAE':>11}{'평가 MAE':>11}{'평가 R^2':>10}")
print("-" * 66)
for name, build in FEATURES.items():
cols = build()
Xtr, Xte = design(cols, tr), design(cols, te)
theta = np.linalg.lstsq(Xtr, ratio[tr], rcond=None)[0]
pred_tr, pred_te = Xtr @ theta, Xte @ theta
mae_tr = np.abs(pred_tr - ratio[tr]).mean()
mae_te = np.abs(pred_te - ratio[te]).mean()
ss_res = ((pred_te - ratio[te]) ** 2).sum()
ss_tot = ((ratio[te] - ratio[te].mean()) ** 2).sum()
print(f"{name:<24}{Xtr.shape[1]:>7}{mae_tr:>11.4f}{mae_te:>11.4f}{1 - ss_res / ss_tot:>10.4f}")$ python3 features.py
설계행렬 열 구성 열 수 학습 MAE 평가 MAE 평가 R^2
------------------------------------------------------------------
entropy 2 0.1068 0.1060 0.8274
entropy + log10(size) 3 0.1041 0.1043 0.8318
entropy + entropy^2 3 0.0489 0.0489 0.9494
entropy + entropy^2 + ^3 4 0.0438 0.0470 0.9546
위 3개 + log10(size) 5 0.0438 0.0469 0.9547
entropy² 열 하나로 평가 MAE가 0.1060에서 0.0489로 절반 아래가 됐다. 엔트로피가 높을수록 1에 붙어 평평해지는 곡선을 직선이 표현하지 못했던 것이고, log10(size)와 3차항의 추가 기여는 0.002 이하였다.
정규방정식이 깨질 때
열을 마음대로 늘리다 보면 서로 겹치는 열이 생긴다. 그러면 XᵀX가 역행렬을 갖지 못한다.
#!/usr/bin/env python3
"""정규방정식이 깨지는 경우와 lstsq가 버티는 이유를 확인한다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
def report(name, cols):
X = np.column_stack(cols + [np.ones(len(tr))])
XtX = X.T @ X
print(f"[{name}] cond(X^T X) = {np.linalg.cond(XtX):.3e}")
try:
theta = np.linalg.solve(XtX, X.T @ ratio[tr])
print(f" solve -> {np.array2string(theta, precision=4, floatmode='fixed')}")
except np.linalg.LinAlgError as e:
print(f" solve -> LinAlgError: {e}")
theta, *_ = np.linalg.lstsq(X, ratio[tr], rcond=None)
print(f" lstsq -> {np.array2string(theta, precision=4, floatmode='fixed')}")
print(f" lstsq MSE = {np.mean((X @ theta - ratio[tr]) ** 2):.8f}")
e = entropy[tr]
report("정상: entropy", [e])
report("완전 중복: entropy를 두 번", [e, e.copy()])
report("거의 중복: entropy와 entropy+1e-9", [e, e + 1e-9])$ python3 collinear.py
[정상: entropy] cond(X^T X) = 5.811e+02
solve -> [ 0.1936 -0.6163]
lstsq -> [ 0.1936 -0.6163]
lstsq MSE = 0.01593235
[완전 중복: entropy를 두 번] cond(X^T X) = 7.234e+17
solve -> LinAlgError: Singular matrix
lstsq -> [ 0.0968 0.0968 -0.6163]
lstsq MSE = 0.01593235
[거의 중복: entropy와 entropy+1e-9] cond(X^T X) = 5.401e+15
solve -> [ 0.1266 0.0670 -0.6163]
lstsq -> [ 0.0968 0.0968 -0.6163]
lstsq MSE = 0.01593235
완전 중복 열에서 solve는 Singular matrix로 죽고, lstsq는 계수를 0.0968씩 반으로 나눈 최소 노름 해를 준다. 더 위험한 쪽은 1e-9만 다른 경우로, solve가 오류 없이 0.1266과 0.0670이라는 해석할 수 없는 계수를 내놓는다.
| 조건수 cond(XᵀX) | np.linalg.solve | np.linalg.lstsq |
|---|---|---|
| 5.8e+02 (정상) | 정확 | 정확 |
| 5.4e+15 (거의 중복) | 계수가 요동침 (오류 없음) | 안정적인 최소 노름 해 |
| 7.2e+17 (완전 중복) | LinAlgError | 안정적인 최소 노름 해 |
실무에서 lstsq(내부적으로 SVD 기반 의사역행렬)를 쓰라고 하는 이유가 여기에 있다. 조건수를 찍어보는 습관도 같이 붙이는 게 좋다.
남은 오차는 어디에 있나
전체 MAE 하나로는 모델이 어디서 틀리는지 알 수 없다. 잔차를 그룹별로 갈라 본다.
#!/usr/bin/env python3
"""남은 오차가 어디에 몰려 있는지 확장자별로 본다."""
import numpy as np
from data import load
ext, size, entropy, ratio, tr, te = load()
def fit_predict(cols):
Xtr = np.column_stack([c[tr] for c in cols] + [np.ones(len(tr))])
X = np.column_stack(list(cols) + [np.ones(len(ext))])
theta = np.linalg.lstsq(Xtr, ratio[tr], rcond=None)[0]
return X @ theta
pred1 = fit_predict([entropy])
pred2 = fit_predict([entropy, entropy ** 2])
groups = [(e, np.where(ext[te] == e)[0]) for e in sorted(set(ext[te]))]
groups = [(e, i) for e, i in groups if len(i) >= 6]
print(f"{'확장자':<12}{'개수':>5}{'평균 ratio':>12}{'MAE(1차)':>11}{'MAE(2차)':>11}")
print("-" * 51)
for e, i in sorted(groups, key=lambda g: -np.abs(pred1[te][g[1]] - ratio[te][g[1]]).mean()):
r = ratio[te][i]
m1 = np.abs(pred1[te][i] - r).mean()
m2 = np.abs(pred2[te][i] - r).mean()
print(f"{e:<12}{len(i):>5}{r.mean():>12.3f}{m1:>11.4f}{m2:>11.4f}")$ python3 residual.py
확장자 개수 평균 ratio MAE(1차) MAE(2차)
---------------------------------------------------
(none) 20 0.339 0.1395 0.0689
.svg 19 0.262 0.1159 0.0808
.h 20 0.288 0.0960 0.0497
.gz 26 1.003 0.0752 0.0057
이미 압축된 .gz에서 개선 폭이 가장 컸다(0.0752 → 0.0057). 직선 모델은 압축률을 1 위로 넘겨버리거나 한참 아래로 잡았는데, 2차항이 포화 구간을 표현하면서 정리됐다.
주의사항
- 열을 늘리면 학습 MAE는 거의 언제나 내려간다. 위 표에서도 3차항을 넣자 학습 0.0438 / 평가 0.0470으로 벌어지기 시작한다. 판단은 반드시 평가 쪽 숫자로 한다.
np.linalg.inv(XtX) @ Xty로 쓰지 말 것. 역행렬을 명시적으로 구하는 건solve보다 느리고 수치적으로도 불리하다. 굳이 정규방정식을 쓸 거면solve, 아니면lstsq다.- 절편(bias) 열을 빼먹지 말 것.
np.ones열이 없으면 직선이 원점을 지나야 해서, 데이터가 원점 근처에 없을수록 손해가 크다. - R²는 평가 데이터에서 음수가 될 수 있다. 정의상 “평균으로 예측하는 것보다 나은가”를 재는 값이라, 학습 데이터에서만 잘 맞는 모델은 음수가 나온다.
- 스케일이 크게 다른 열을 섞으면 조건수가 나빠진다.
size를 그대로 넣지 않고log10(size)로 넣은 이유다. 이 문제는 다음 편(경사하강법)에서 훨씬 크게 드러난다.
마무리
최소제곱 선형회귀는 손실을 이차식으로 정의하고 그 최소를 연립방정식 하나로 푸는 것이 전부다. 다음 편에서는 이 닫힌 해를 버리고 기울기를 따라 내려가는 경사하강법으로 같은 답에 도달하면서, 굳이 그래야 하는 이유를 확인한다.
이 시리즈의 다른 글
- 머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이
- 머신러닝 입문 (3) — 경사하강법과 학습률
- 머신러닝 입문 (4) — 로지스틱 회귀와 이진 분류
- 머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기
- 머신러닝 입문 (6) — 자동미분(autograd) 직접 만들기