머신러닝 입문 (4) — 로지스틱 회귀와 이진 분류

압축 저장소를 만든다면 압축률을 정확히 맞히기보다 “이 파일을 압축할 가치가 있는가”를 먼저 판정해야 하고, 잘못 판정하면 CPU를 헛쓰거나 저장 공간을 손해 본다. 이 글에서는 같은 데이터로 원본의 30% 미만으로 줄어드는가를 분류한다. 로지스틱 회귀를 밑바닥부터 구현하고, MSE 대신 로그손실을 쓰는 이유를 기울기로 확인한 뒤, 정확도 하나만 보면 왜 속는지까지 다룬다.

분류 문제로 바꾸고 특성을 늘리기

예측 대상을 ratio < 0.30이라는 0/1 라벨로 바꾼다. 엔트로피 하나로는 이 경계를 거의 설명하지 못해서(뒤에서 확인한다) 바이트 통계 특성 네 개를 추가로 수집했다.

특성의미
entropy바이트 하나의 엔트로피 (1편과 동일)
bigram이웃한 바이트 쌍의 엔트로피 — 반복 구조를 잡는다
ascii인쇄 가능 ASCII 바이트의 비율
uniq등장한 고유 바이트 수 / 256
run앞 바이트와 같은 값이 이어지는 비율
#!/usr/bin/env python3
"""1편의 (크기, 엔트로피, gzip 크기)에 바이트 통계 특성 네 개를 더해 다시 수집한다."""
import gzip, math, random
from collections import Counter
from pathlib import Path

DIRS = ["/usr/bin", "/usr/lib/x86_64-linux-gnu", "/usr/share/doc",
        "/usr/share/man", "/usr/share/icons", "/usr/include"]

def features(data: bytes) -> dict:
    n = len(data)
    counts = Counter(data)
    ent = -sum((c / n) * math.log2(c / n) for c in counts.values())

    # 2-gram 엔트로피: 바이트 하나가 아니라 이웃한 쌍의 다양성 (비용 때문에 앞 64KB만)
    head = data[:65536]
    bigrams = Counter(zip(head, head[1:]))
    m = sum(bigrams.values())
    bient = -sum((c / m) * math.log2(c / m) for c in bigrams.values()) if m else 0.0

    # 같은 바이트가 연달아 나오는 비율 (0으로 채운 영역, 들여쓰기 등)
    runs = sum(1 for a, b in zip(head, head[1:]) if a == b)

    return {
        "size":     n,
        "entropy":  ent,
        "bigram":   bient,
        "ascii":    sum(counts[c] for c in range(32, 127)) / n,
        "uniq":     len(counts) / 256.0,
        "run":      runs / max(1, len(head) - 1),
        "gz_size":  len(gzip.compress(data, 9)),
    }

def collect(limit_per_dir=60, min_size=4096, max_size=2_000_000):
    random.seed(42)                      # 1편과 같은 시드 → 같은 파일 집합
    rows = []
    for d in DIRS:
        files = [p for p in Path(d).rglob("*") if p.is_file() and not p.is_symlink()]
        random.shuffle(files)
        taken = 0
        for p in files:
            if taken >= limit_per_dir:
                break
            try:
                data = p.read_bytes()
            except OSError:
                continue
            if not (min_size <= len(data) <= max_size):
                continue
            row = {"ext": p.suffix.lower() or "(none)"}
            row.update(features(data))
            rows.append(row)
            taken += 1
    return rows

if __name__ == "__main__":
    rows = collect()
    cols = ["ext", "size", "entropy", "bigram", "ascii", "uniq", "run", "gz_size"]
    with open("files2.csv", "w") as fp:
        fp.write(",".join(cols) + "\n")
        for r in rows:
            fp.write(",".join(f"{r[c]:.4f}" if isinstance(r[c], float) else str(r[c])
                              for c in cols) + "\n")
    print(f"수집한 파일: {len(rows)}개 -> files2.csv")
$ python3 collect2.py
수집한 파일: 360개 -> files2.csv

시드가 1편과 같아 파일 집합도 그대로다. 읽어들이는 부분은 다시 공용 모듈로 뺐다.

"""files2.csv(특성 6개)를 읽어 학습/평가로 나눈다."""
import csv
import numpy as np

COLS = ["entropy", "bigram", "ascii", "uniq", "run"]

def load(path="files2.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([float(r["size"]) for r in rows])
    F    = {c: np.array([float(r[c]) for r in rows]) for c in COLS}
    F["logsize"] = np.log10(size)
    ratio = np.array([float(r["gz_size"]) for r in rows]) / size

    idx = np.random.default_rng(seed).permutation(len(rows))
    n = int(len(rows) * train_ratio)
    return ext, F, ratio, idx[:n], idx[n:]

왜 MSE가 아니라 로그손실인가

출력을 0~1로 눌러주는 시그모이드 σ(z) = 1/(1+e⁻ᶻ)를 씌운 뒤 회귀처럼 MSE를 쓰면 될 것 같지만, 두 가지가 어긋난다.

#!/usr/bin/env python3
"""분류에 MSE 대신 로그손실을 쓰는 이유를 기울기 크기로 확인한다."""
import numpy as np

def sigmoid(z):
    return 1 / (1 + np.exp(-z))

# 정답이 1인 표본 하나. z가 음수일수록 '확신에 차서 틀린' 상태다.
print(f"{'z':>6}{'예측 p':>10}{'MSE 손실':>11}{'MSE 기울기':>13}"
      f"{'로그손실':>11}{'로그손실 기울기':>16}")
print("-" * 68)
for z in (-8, -4, -2, 0, 2, 4):
    p = sigmoid(z)
    # d/dz 이므로 시그모이드 미분 p(1-p)가 곱해진다
    mse_grad  = 2 * (p - 1) * p * (1 - p)
    ll_grad   = p - 1
    print(f"{z:>6}{p:>10.6f}{(p - 1) ** 2:>11.6f}{mse_grad:>13.2e}"
          f"{-np.log(p):>11.4f}{ll_grad:>16.4f}")

# 손실을 파라미터 하나짜리 문제로 두고 볼록한지 본다
x = np.array([-2.0, -1.0, 1.0, 2.0])
y = np.array([0.0, 0.0, 1.0, 1.0])
w = np.linspace(-6, 6, 2401)
mse = [(np.mean((sigmoid(wi * x) - y) ** 2)) for wi in w]
ll  = [float(-np.mean(y * np.log(sigmoid(wi * x)) +
                      (1 - y) * np.log(1 - sigmoid(wi * x)))) for wi in w]

def curvature_sign_changes(f):
    d2 = np.diff(np.array(f), 2)
    return int((np.sign(d2[:-1]) != np.sign(d2[1:])).sum())

print(f"\nw를 -6~6으로 훑으며 이계차분의 부호가 바뀐 횟수 (0이면 볼록)")
print(f"    MSE  손실 : {curvature_sign_changes(mse)}회")
print(f"    로그손실  : {curvature_sign_changes(ll)}회")
$ python3 why_logloss.py
     z      예측 p     MSE 손실      MSE 기울기       로그손실        로그손실 기울기
--------------------------------------------------------------------
    -8  0.000335   0.999329    -6.70e-04     8.0003         -0.9997
    -4  0.017986   0.964351    -3.47e-02     4.0181         -0.9820
    -2  0.119203   0.775803    -1.85e-01     2.1269         -0.8808
     0  0.500000   0.250000    -2.50e-01     0.6931         -0.5000
     2  0.880797   0.014209    -2.50e-02     0.1269         -0.1192
     4  0.982014   0.000324    -6.35e-04     0.0181         -0.0180

w를 -6~6으로 훑으며 이계차분의 부호가 바뀐 횟수 (0이면 볼록)
    MSE  손실 : 1회
    로그손실  : 0회

정답이 1인데 z=-8로 완전히 틀린 지점에서 MSE의 기울기는 6.70e-04로 학습이 사실상 멈추고, 로그손실은 -0.9997로 가장 크게 고친다. 게다가 시그모이드+MSE는 볼록하지도 않아(부호 변화 1회) 경사하강이 지역 최소에 갇힐 수 있다.

로지스틱 회귀 구현

선형회귀에서 바뀌는 건 예측에 시그모이드를 씌우고 손실을 로그손실로 바꾸는 두 줄이다. 기울기 식의 형태가 선형회귀와 같아서 3편의 경사하강 루프를 그대로 쓴다.

#!/usr/bin/env python3
"""'원본의 30% 미만으로 줄어드는가'를 로지스틱 회귀로 분류한다."""
import numpy as np
from data2 import load

NAMES = ["entropy", "bigram", "ascii", "uniq", "run"]

ext, F, ratio, tr, te = load()
y = (ratio < 0.30).astype(float)                 # 1 = 강하게 압축됨

X = np.column_stack([F[n] for n in NAMES])
mu, sd = X[tr].mean(0), X[tr].std(0)             # 표준화 통계는 학습 데이터에서만
Z = np.column_stack([(X - mu) / sd, np.ones(len(y))])

def sigmoid(z):
    return 1.0 / (1.0 + np.exp(-np.clip(z, -500, 500)))

def logloss(t, rows):
    p = np.clip(sigmoid(Z[rows] @ t), 1e-12, 1 - 1e-12)
    return float(-np.mean(y[rows] * np.log(p) + (1 - y[rows]) * np.log(1 - p)))

theta = np.zeros(Z.shape[1])
lr = 0.5
print(f"{'step':>6}{'학습 로그손실':>14}{'평가 로그손실':>14}{'평가 정확도':>12}")
for step in range(3001):
    if step in (0, 100, 500, 1000, 2000, 3000):
        acc = ((sigmoid(Z[te] @ theta) >= 0.5) == y[te]).mean()
        print(f"{step:>6}{logloss(theta, tr):>14.5f}{logloss(theta, te):>14.5f}{acc:>12.4f}")
    # 로그손실의 기울기는 (1/n) Z^T (sigmoid(Z theta) - y) — 선형회귀와 형태가 같다
    theta -= lr * (Z[tr].T @ (sigmoid(Z[tr] @ theta) - y[tr])) / len(tr)

print("\n" + f"{'특성':<10}{'계수':>10}")
for name, w in zip(NAMES + ["(절편)"], theta):
    print(f"{name:<10}{w:>10.4f}")
np.savez("logistic_out.npz", prob=sigmoid(Z[te] @ theta), y=y[te])
$ python3 logistic.py
  step       학습 로그손실       평가 로그손실      평가 정확도
     0       0.69315       0.69315      0.3761
   100       0.38956       0.39571      0.7706
   500       0.37629       0.37292      0.8165
  1000       0.37280       0.36698      0.8165
  2000       0.37024       0.36564      0.8257
  3000       0.36925       0.36673      0.8257

특성                계수
entropy      -0.8970
bigram       -2.6521
ascii         3.4886
uniq          0.6303
run           0.7102
(절편)         -3.4033

계수를 보면 ascii가 +3.49로 가장 크게 양성(잘 압축됨) 쪽으로 밀고, bigram이 -2.65로 반대로 당긴다. 표준화를 했기 때문에 계수 크기를 서로 비교할 수 있다.

특성이 모델보다 먼저다

특성을 하나씩 더해 가며 같은 모델을 다시 학습시켜 본다.

#!/usr/bin/env python3
"""특성을 하나씩 더해 가며 분류 성능이 어떻게 변하는지 본다."""
import numpy as np
from data2 import load

ext, F, ratio, tr, te = load()
y = (ratio < 0.30).astype(float)

def fit(names, steps=3000, lr=0.5):
    X = np.column_stack([F[n] for n in names])
    mu, sd = X[tr].mean(0), X[tr].std(0)
    Z = np.column_stack([(X - mu) / sd, np.ones(len(y))])
    t = np.zeros(Z.shape[1])
    sig = lambda z: 1 / (1 + np.exp(-np.clip(z, -500, 500)))
    for _ in range(steps):
        t -= lr * (Z[tr].T @ (sig(Z[tr] @ t) - y[tr])) / len(tr)
    p = sig(Z[te] @ t)
    ll = -np.mean(y[te] * np.log(np.clip(p, 1e-12, 1)) +
                  (1 - y[te]) * np.log(np.clip(1 - p, 1e-12, 1)))
    return ((p >= 0.5) == y[te]).mean(), float(ll)

major = max(y[te].mean(), 1 - y[te].mean())
print(f"평가 표본 {len(te)}개 중 양성 {int(y[te].sum())}개 → 다수 클래스 기준선 정확도 {major:.4f}\n")
print(f"{'특성 구성':<44}{'정확도':>9}{'로그손실':>11}")
print("-" * 64)
for names in (["entropy"], ["entropy", "logsize"], ["entropy", "bigram"],
              ["entropy", "bigram", "ascii"],
              ["entropy", "bigram", "ascii", "uniq", "run"],
              ["entropy", "bigram", "ascii", "uniq", "run", "logsize"]):
    acc, ll = fit(names)
    print(f"{' + '.join(names):<44}{acc:>9.4f}{ll:>11.5f}")
$ python3 feat_gain.py
평가 표본 109개 중 양성 41개 → 다수 클래스 기준선 정확도 0.6239

특성 구성                                             정확도       로그손실
----------------------------------------------------------------
entropy                                        0.6147    0.53439
entropy + logsize                              0.6147    0.52228
entropy + bigram                               0.5963    0.53702
entropy + bigram + ascii                       0.8257    0.37987
entropy + bigram + ascii + uniq + run          0.8257    0.36673
entropy + bigram + ascii + uniq + run + logsize   0.8073    0.37542

엔트로피만 쓰면 정확도 0.6147로 다수 클래스를 무조건 찍는 기준선 0.6239보다도 낮다. 판을 뒤집은 것은 ascii 한 열이었고(0.5963 → 0.8257), logsize는 마지막에 넣자 오히려 0.8073으로 떨어뜨렸다.

정확도 하나로는 부족하다

정확도 0.8257이 좋은 숫자인지는 무엇을 놓쳤는지 봐야 알 수 있다.

#!/usr/bin/env python3
"""정확도 하나로는 안 되는 이유: 혼동행렬과 임계값 조정."""
import numpy as np

d = np.load("logistic_out.npz")
prob, y = d["prob"], d["y"]

def counts(thr):
    pred = prob >= thr
    tp = int(((pred == 1) & (y == 1)).sum())
    fp = int(((pred == 1) & (y == 0)).sum())
    fn = int(((pred == 0) & (y == 1)).sum())
    tn = int(((pred == 0) & (y == 0)).sum())
    return tp, fp, fn, tn

tp, fp, fn, tn = counts(0.5)
print("임계값 0.5의 혼동행렬")
print(f"{'':>12}{'예측 양성':>10}{'예측 음성':>10}")
print(f"{'실제 양성':>10}{tp:>12}{fn:>10}")
print(f"{'실제 음성':>10}{fp:>12}{tn:>10}")

print(f"\n{'임계값':>8}{'정밀도':>9}{'재현율':>9}{'F1':>8}{'정확도':>9}  (TP/FP/FN/TN)")
print("-" * 62)
for thr in (0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8):
    tp, fp, fn, tn = counts(thr)
    prec = tp / (tp + fp) if tp + fp else 0.0
    rec  = tp / (tp + fn) if tp + fn else 0.0
    f1   = 2 * prec * rec / (prec + rec) if prec + rec else 0.0
    acc  = (tp + tn) / len(y)
    print(f"{thr:>8.1f}{prec:>9.3f}{rec:>9.3f}{f1:>8.3f}{acc:>9.3f}"
          f"  ({tp}/{fp}/{fn}/{tn})")
$ python3 metrics.py
임계값 0.5의 혼동행렬
                 예측 양성     예측 음성
     실제 양성          36         5
     실제 음성          14        54

     임계값      정밀도      재현율      F1      정확도  (TP/FP/FN/TN)
--------------------------------------------------------------
     0.2    0.641    1.000   0.781    0.789  (41/23/0/45)
     0.3    0.661    1.000   0.796    0.807  (41/21/0/47)
     0.4    0.672    0.951   0.788    0.807  (39/19/2/49)
     0.5    0.720    0.878   0.791    0.826  (36/14/5/54)
     0.6    0.730    0.659   0.692    0.780  (27/10/14/58)
     0.7    0.750    0.366   0.492    0.716  (15/5/26/63)
     0.8    0.800    0.195   0.314    0.679  (8/2/33/66)

임계값 0.5에서는 압축 가능한 파일 5개를 놓쳤고(FN), 0.3으로 낮추면 하나도 안 놓치는 대신 헛수고하는 파일이 14개에서 21개로 는다. 어느 쪽이 비싼지(놓친 저장 공간 vs 헛돌린 CPU)가 임계값을 정하며, 모델이 정해주는 값이 아니다.

클래스가 완전히 갈리면 가중치가 멈추지 않는다

라벨 기준을 ratio < 0.50으로 바꾸면 엔트로피 하나로 두 클래스가 겹치지 않게 갈린다. 같은 경사하강을 20만 스텝까지 돌리며 가중치를 찍어본다.

#!/usr/bin/env python3
"""클래스가 완전히 갈리는 라벨에서는 로지스틱 회귀 가중치가 멈추지 않는다."""
import numpy as np
from data2 import load

ext, F, ratio, tr, te = load()
e = F["entropy"]
Z = np.column_stack([(e - e[tr].mean()) / e[tr].std(), np.ones(len(e))])

for thr in (0.30, 0.50):
    y = (ratio < thr).astype(float)
    print(f"[라벨: ratio < {thr:.2f}]  양성 entropy 최대 {e[y == 1].max():.3f}"
          f" / 음성 entropy 최소 {e[y == 0].min():.3f}")
    theta = np.zeros(2)
    for step in range(1, 200_001):
        p = 1 / (1 + np.exp(-np.clip(Z[tr] @ theta, -500, 500)))
        theta -= 0.5 * Z[tr].T @ (p - y[tr]) / len(tr)
        if step in (1_000, 10_000, 100_000, 200_000):
            print(f"    {step:>7}스텝  w = {theta[0]:8.3f}")
$ python3 separable.py
[라벨: ratio < 0.30]  양성 entropy 최대 5.550 / 음성 entropy 최소 3.287
       1000스텝  w =   -1.184
      10000스텝  w =   -1.184
     100000스텝  w =   -1.184
     200000스텝  w =   -1.184
[라벨: ratio < 0.50]  양성 entropy 최대 6.405 / 음성 entropy 최소 6.480
       1000스텝  w =   -6.039
      10000스텝  w =   -9.921
     100000스텝  w =  -20.101
     200000스텝  w =  -25.980

두 클래스가 겹치는 0.30 라벨에서는 가중치가 -1.184에 멈췄지만, 엔트로피 6.405와 6.480 사이에서 완전히 갈리는 0.50 라벨에서는 스텝을 늘릴수록 계속 커진다. 손실을 조금이라도 더 줄이려면 시그모이드를 계단 함수에 가깝게 세우는 수밖에 없기 때문이다.

주의사항

  • 정확도는 클래스가 치우치면 무의미해진다. 양성이 5%인 데이터에서는 전부 음성으로 찍어도 정확도 95%가 나온다. 항상 다수 클래스 기준선을 같이 적어둔다.
  • 표준화 통계는 학습 데이터에서만 구한다. 위 코드의 mu, sd = X[tr].mean(0), X[tr].std(0)가 그것이다. 전체 데이터로 구하면 평가 정보가 학습에 새어 들어간다.
  • 시그모이드는 오버플로를 낸다. np.exp(-z)에 큰 음수 z가 들어가면 경고와 함께 inf가 된다. np.clip(z, -500, 500)이 최소한의 방어다. 로그도 마찬가지로 log(0)을 막아야 한다.
  • 두 클래스가 완전히 분리되면 계수가 발산한다. 위 실측처럼 스텝 수에 따라 가중치가 달라지므로, 규제(regularization)를 붙이거나 조기 종료해야 한다. 규제는 2단계에서 다룬다.
  • 임계값 0.5는 기본값일 뿐이다. 위 표처럼 0.2~0.8을 훑어보고 목적에 맞는 지점을 골라야 한다.

마무리

로지스틱 회귀는 선형회귀에 시그모이드와 로그손실을 얹은 것이고, 성능을 가른 것은 알고리즘이 아니라 ascii라는 특성 하나였다. 다음 편에서는 층을 쌓았을 때 기울기가 뒤로 전달되는 역전파를 손으로 계산해 코드와 맞춰본다.

이 시리즈의 다른 글

참고

답글 남기기