압축 저장소를 만든다면 압축률을 정확히 맞히기보다 “이 파일을 압축할 가치가 있는가”를 먼저 판정해야 하고, 잘못 판정하면 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라는 특성 하나였다. 다음 편에서는 층을 쌓았을 때 기울기가 뒤로 전달되는 역전파를 손으로 계산해 코드와 맞춰본다.
이 시리즈의 다른 글
- 머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이
- 머신러닝 입문 (2) — 선형회귀를 밑바닥부터 구현하기
- 머신러닝 입문 (3) — 경사하강법과 학습률
- 머신러닝 입문 (5) — 역전파를 손으로 계산하고 코드로 검증하기
- 머신러닝 입문 (6) — 자동미분(autograd) 직접 만들기
참고
- numpy.clip 문서
- scikit-learn — Metrics and scoring — 정밀도·재현율 정의