머신러닝 입문 (1) — 규칙 기반 코드와 학습 기반 코드의 차이

리눅스에서 개발하다 보면 대부분의 문제를 조건과 분기로 푼다. 그런데 이 파일을 gzip으로 압축하면 얼마나 줄어들까 같은 문제는 확장자로 if를 짜봐도 확장자 없는 실행 파일이나 이미 압축된 .gz에서 막히고, 머신러닝은 규칙을 사람이 짜는 대신 데이터에서 규칙에 해당하는 숫자를 찾는 쪽으로 접근을 바꾼다. 이 글에서는 시스템에서 직접 수집한 파일 360개로 두 방식의 차이를 재보고, 학습이 결국 무엇을 하는 일인지 코드로 확인한다.

풀어볼 문제와 데이터 만들기

압축률(gz_size / size)을 파일 크기와 바이트 엔트로피 두 특성으로 맞히는 문제로 잡는다. 엔트로피는 바이트 값이 고르게 흩어진 정도를 0~8로 나타내며, 이미 압축된 파일일수록 8에 가깝다.

#!/usr/bin/env python3
"""파일마다 (크기, 바이트 엔트로피, gzip 압축 후 크기)를 측정해 CSV로 남긴다."""
import gzip, math, random, sys
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 entropy(data: bytes) -> float:
    counts = Counter(data)
    n = len(data)
    return -sum((c / n) * math.log2(c / n) for c in counts.values())

def collect(limit_per_dir=60, min_size=4096, max_size=2_000_000):
    random.seed(42)
    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
            gz = len(gzip.compress(data, 9))
            rows.append((p.suffix.lower() or "(none)", len(data), entropy(data), gz))
            taken += 1
    return rows

if __name__ == "__main__":
    rows = collect()
    with open("files.csv", "w") as f:
        f.write("ext,size,entropy,gz_size\n")
        for ext, size, ent, gz in rows:
            f.write(f"{ext},{size},{ent:.4f},{gz}\n")
    print(f"수집한 파일: {len(rows)}개 -> files.csv")

실제 시스템 디렉터리에서 표본을 모은다. 정답을 만들어내는 것이 아니라 gzip.compress()로 진짜 압축해서 크기를 잰다.

$ python3 collect.py && head -4 files.csv
수집한 파일: 360개 -> files.csv

$ head -4 files.csv
ext,size,entropy,gz_size
(none),147120,5.8578,60330
(none),96864,5.3309,35137
(none),51720,4.9529,19381

텍스트(.h, .html, .svg), 바이너리(확장자 없음, .so), 이미 압축된 파일(.gz)이 골고루 섞이도록 디렉터리를 골랐다.

손으로 짠 규칙과 데이터에서 찾은 직선

사람이 상식으로 짠 확장자 규칙과 최소제곱으로 데이터에서 구한 직선을 비교한다. 기준선으로 “항상 평균값을 답하는” 예측기도 함께 둔다.

#!/usr/bin/env python3
"""같은 문제를 (1) 손으로 짠 규칙 (2) 데이터로 학습한 직선 으로 각각 풀어 비교한다."""
import csv
import numpy as np

TEXT_EXT = {".h", ".html", ".txt", ".pl", ".pm", ".svg", ".1", ".0", ".openbsd"}

rows = list(csv.DictReader(open("files.csv")))
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   # 예측 대상

rng = np.random.default_rng(0)
idx = rng.permutation(len(rows))
n_train = int(len(rows) * 0.7)
tr, te = idx[:n_train], idx[n_train:]

def mae(pred, true):
    return float(np.mean(np.abs(pred - true)))

# (1) 사람이 짠 규칙: 확장자가 텍스트류면 잘 줄고, 아니면 안 줄 것이다
def rule(ext_arr):
    return np.where(np.isin(ext_arr, list(TEXT_EXT)), 0.30, 0.90)

# (2) 학습: ratio ≈ w*entropy + b 의 w, b를 데이터에서 찾는다 (최소제곱)
X_tr = np.column_stack([entropy[tr], np.ones(len(tr))])
w, b = np.linalg.lstsq(X_tr, ratio[tr], rcond=None)[0]

print(f"학습 데이터 {len(tr)}개 / 평가 데이터 {len(te)}개")
print(f"찾아낸 파라미터: ratio = {w:.4f} * entropy + {b:.4f}")
print()
print(f"{'방법':<28}{'평가 MAE':>10}")
print("-" * 38)
print(f"{'항상 평균값으로 예측':<24}{mae(np.full(len(te), ratio[tr].mean()), ratio[te]):>12.4f}")
print(f"{'손으로 짠 확장자 규칙':<24}{mae(rule(ext[te]), ratio[te]):>12.4f}")
print(f"{'학습한 직선 (entropy)':<24}{mae(w * entropy[te] + b, ratio[te]):>12.4f}")
$ python3 compare.py
학습 데이터 251개 / 평가 데이터 109개
찾아낸 파라미터: ratio = 0.1936 * entropy + -0.6163

방법                              평가 MAE
--------------------------------------
항상 평균값으로 예측                   0.2588
손으로 짠 확장자 규칙                  0.2322
학습한 직선 (entropy)              0.1060

손으로 짠 확장자 규칙은 평균값 예측보다 겨우 나은 수준에 그쳤다. 반면 특성 하나짜리 직선이 오차를 절반 이하로 줄였는데, 사람이 짠 규칙이 나빠서가 아니라 확장자보다 엔트로피가 압축률과 훨씬 직접적으로 이어져 있기 때문이다.

학습은 파라미터를 찾는 일이다

위 코드에서 lstsq가 한 일이 곧 학습이다. 특별한 것이 아니라는 점을 확인하기 위해, 파라미터 공간을 무작정 뒤져서 같은 답에 도달하는지 본다.

#!/usr/bin/env python3
"""'학습'이 무엇인지 확인한다: 손실을 가장 작게 만드는 파라미터를 찾는 일이다."""
import csv
import numpy as np

rows    = list(csv.DictReader(open("files.csv")))
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

def loss(w, b):                      # 평균제곱오차(MSE)
    return float(np.mean((w * entropy + b - ratio) ** 2))

# 방법 1: 파라미터 공간을 무작위로 뒤진다
rng = np.random.default_rng(0)
best = min(((loss(w, b), w, b) for w, b in rng.uniform(-2, 2, size=(200_000, 2))))
print(f"무작위 탐색 20만 회 : w={best[1]:+.4f} b={best[2]:+.4f}  MSE={best[0]:.6f}")

# 방법 2: 최소제곱의 닫힌 해로 한 번에 구한다
X = np.column_stack([entropy, np.ones(len(rows))])
w, b = np.linalg.lstsq(X, ratio, rcond=None)[0]
print(f"최소제곱 닫힌 해     : w={w:+.4f} b={b:+.4f}  MSE={loss(w, b):.6f}")
$ python3 search.py
무작위 탐색 20만 회 : w=+0.1888 b=-0.5892  MSE=0.016017
최소제곱 닫힌 해     : w=+0.1923 b=-0.6084  MSE=0.015991

20만 번을 찍어도 닫힌 해의 MSE(0.015991)에 못 미친다. 학습은 손실을 가장 작게 만드는 파라미터를 고르는 탐색이고, 그 탐색을 제대로 하는 방법이 다음 편부터 다룰 경사하강법이다.

지도학습의 구성 요소

위 코드에 이미 지도학습의 요소가 전부 들어 있다. 용어를 코드의 어느 부분에 해당하는지로 정리하면 다음과 같다.

용어이 예제에서는코드
특성(feature)바이트 엔트로피entropy
레이블(label)실제 압축률ratio
모델(model)직선 하나w * entropy + b
파라미터(parameter)기울기와 절편w, b
손실 함수(loss)평균제곱오차np.mean((pred - ratio) ** 2)
학습(training)손실이 최소인 파라미터 찾기np.linalg.lstsq(...)
일반화(generalization)안 본 파일에서도 맞히는가mae(..., ratio[te])

머신러닝의 갈래도 같은 틀로 나뉜다. 위 문제처럼 정답이 주어지면 지도학습, 정답 없이 구조만 찾으면 비지도학습, 행동의 결과로 받는 보상으로 배우면 강화학습이다.

학습 데이터 밖에서는 무너진다

직선이 오차를 줄였다고 해서 압축을 이해한 것은 아니다. 학습에서 .gz를 빼고 같은 방식으로 맞춘 뒤, 그 모델로 .gz를 예측해본다.

#!/usr/bin/env python3
"""학습 데이터에 없던 종류를 만나면 어떻게 되는지 본다."""
import csv
import numpy as np

rows    = list(csv.DictReader(open("files.csv")))
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

def fit(mask):
    X = np.column_stack([entropy[mask], np.ones(mask.sum())])
    return np.linalg.lstsq(X, ratio[mask], rcond=None)[0]

def mae(w, b, mask):
    return float(np.mean(np.abs(w * entropy[mask] + b - ratio[mask])))

gz  = ext == ".gz"          # 이미 압축된 파일 (더 줄지 않는다)
all_ = np.ones(len(rows), dtype=bool)

for name, train_mask in [("전체(.gz 포함)", all_), (".gz 제외", ~gz)]:
    w, b = fit(train_mask)
    lo, hi = entropy[train_mask].min(), entropy[train_mask].max()
    pred = w * entropy[gz] + b
    print(f"[{name}] 학습 entropy {lo:.2f}~{hi:.2f} | "
          f"ratio = {w:+.4f}*entropy {b:+.4f}")
    print(f"    .gz(entropy {entropy[gz].min():.2f}~{entropy[gz].max():.2f}) "
          f"예측 {pred.min():.3f}~{pred.max():.3f} / 실제 평균 {ratio[gz].mean():.3f} "
          f"| MAE {mae(w, b, gz):.4f}")
$ python3 generalize.py
[전체(.gz 포함)] 학습 entropy 1.81~8.00 | ratio = +0.1923*entropy -0.6084
    .gz(entropy 7.95~8.00) 예측 0.919~0.929 / 실제 평균 1.003 | MAE 0.0788
[.gz 제외] 학습 entropy 1.81~7.05 | ratio = +0.0424*entropy +0.1003
    .gz(entropy 7.95~8.00) 예측 0.437~0.439 / 실제 평균 1.003 | MAE 0.5649

.gz를 빼자 기울기가 0.19에서 0.04로 납작해져, 엔트로피 8.0 부근의 .gz를 실제 1.003 대신 0.44로 예측했다(MAE 0.0788 → 0.5649). 모델은 학습에서 본 범위 안에서만 신뢰할 수 있다.

주의사항

  • 예측이 잘 나왔는지는 학습에 쓰지 않은 데이터로만 판단한다. 학습 데이터에서 잰 오차는 시험 문제를 미리 보고 푼 점수와 같다.
  • 좋아 보이는 숫자는 항상 기준선(baseline)과 비교해야 의미가 생긴다. 여기서는 "항상 평균값"이 그 기준선이고, 손으로 짠 규칙은 그것을 거의 넘지 못했다.
  • 모델을 바꾸기 전에 특성부터 의심한다. 이 예제에서 성능을 가른 것은 알고리즘이 아니라 확장자 대신 엔트로피를 쓴 선택이었다.
  • 학습된 파라미터는 데이터 분포에 묶여 있다. 배포 환경의 파일 구성이 학습 때와 다르면 같은 모델이 전혀 다른 오차를 낸다.
  • 재현을 위해 난수 시드를 고정했다(random.seed(42), default_rng(0)). 시드를 바꾸면 표본이 달라져 수치도 조금씩 달라진다.

마무리

머신러닝은 규칙을 사람이 정하는 대신 손실을 최소화하는 파라미터를 데이터에서 찾고, 그 결과를 안 본 데이터로 검증하는 일이다. 이 편에서는 최소제곱의 닫힌 해로 한 번에 구했지만 대부분의 모델에는 그런 해가 없어서, 다음 편부터 그 파라미터를 직접 구하는 과정을 하나씩 뜯어본다.

이 시리즈의 다른 글

참고

답글 남기기