머신러닝 입문 (2) — 선형회귀를 밑바닥부터 구현하기

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로 부동소수점 오차 수준이다. 해 주변에서 wb를 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

완전 중복 열에서 solveSingular matrix로 죽고, lstsq는 계수를 0.0968씩 반으로 나눈 최소 노름 해를 준다. 더 위험한 쪽은 1e-9만 다른 경우로, solve가 오류 없이 0.12660.0670이라는 해석할 수 없는 계수를 내놓는다.

조건수 cond(XᵀX)np.linalg.solvenp.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)로 넣은 이유다. 이 문제는 다음 편(경사하강법)에서 훨씬 크게 드러난다.

마무리

최소제곱 선형회귀는 손실을 이차식으로 정의하고 그 최소를 연립방정식 하나로 푸는 것이 전부다. 다음 편에서는 이 닫힌 해를 버리고 기울기를 따라 내려가는 경사하강법으로 같은 답에 도달하면서, 굳이 그래야 하는 이유를 확인한다.

이 시리즈의 다른 글

참고

답글 남기기