본문 바로가기
AI/참고자료

[AI] 머신러닝 수학 5대 핵심 개념 (NumPy 코드로 실습)

by SeungyubLee 2025. 10. 21.

1. 미적분, 2. 선형대수 , 3. 확률·통계, 4. 회귀·추정, 5. 이산수학·그래프

# ML 수학 with NumPy
# 머신러닝 수학 5대 핵심 개념
# 1) 미적분: MSE 손실 미분, 경사하강법으로 선형회귀 파라미터 학습
# 2) 선형대수: y = W@x + b 선형 변환, 공분산의 고유분해를 이용한 간단한 PCA
# 3) 확률·통계: 로지스틱 회귀 확률(sigmoid)과 이진 크로스엔트로피, 그라디언트 업데이트
# 4) 회귀·추정: OLS(정규방정식) vs 경사하강법 비교, 정규분포의 MLE(평균, 분산)
# 5) 이산수학·그래프: 인접행렬의 행정규화를 활용한 간단한 메시지 패싱(GNN 유사)
# 필요 라이브러리: NumPy

import numpy as np

# 출력 보기 좋게 포맷
np.set_printoptions(precision=4, suppress=True, linewidth=120)

def main():
    print("====================================")
    print("1) 미적분 — 기울기와 경사하강법 (선형회귀)")
    print("====================================")

    rng = np.random.default_rng(42)
    # 작은 선형 데이터셋 생성: y = 3x + 2 + 잡음
    x = rng.uniform(-2, 2, size=100)
    true_w, true_b = 3.0, 2.0
    y = true_w * x + true_b + rng.normal(0, 0.3, size=x.shape[0])

    # 손실함수(MSE): L = mean( (y - (w*x + b))^2 )
    # 미분:
    #  dL/dw = -(2/N) * sum( x * (y - (w*x + b)) )
    #  dL/db = -(2/N) * sum( (y - (w*x + b)) )
    def mse_and_grads(w, b, x, y):
        y_hat = w * x + b              # 모델 예측
        residual = y - y_hat           # 잔차(오차)
        N = x.shape[0]
        loss = np.mean(residual**2)    # 평균제곱오차
        dL_dw = -2.0 / N * np.sum(x * residual)  # w에 대한 기울기
        dL_db = -2.0 / N * np.sum(residual)      # b에 대한 기울기
        return loss, dL_dw, dL_db

    # 경사하강법으로 w, b 학습
    w, b = 0.0, 0.0
    lr = 0.1
    history = []
    for step in range(80):
        loss, dL_dw, dL_db = mse_and_grads(w, b, x, y)
        w -= lr * dL_dw
        b -= lr * dL_db
        history.append(loss)

    print(f"정답 파라미터: w={true_w:.3f}, b={true_b:.3f}")
    print(f"학습된 파라미터: w={w:.3f}, b={b:.3f}")
    print("MSE 처음→마지막:", f"{history[0]:.4f} → {history[-1]:.4f}")
    print("설명: 손실의 기울기를 이용해 (w,b)를 오차가 줄어드는 방향으로 갱신합니다.\n")

    # ---------------------------------------------------------------
    print("====================================")
    print("2) 선형대수 — 행렬연산 & 간단한 PCA")
    print("====================================")

    # 한 층의 선형 변환: y = W@x + b
    W = np.array([[1.0, 2.0],
                  [3.0, 4.0]])
    x_vec = np.array([5.0, 6.0])
    b_vec = np.array([0.5, -1.0])

    y_vec = W @ x_vec + b_vec
    print("선형 계층 y = W@x + b:", y_vec)

    # 상관이 있는 2차원 데이터 생성 → 공분산 행렬의 고유분해로 PCA
    X = rng.normal(0, 1, size=(200, 2))
    X[:, 1] = 0.8*X[:, 0] + 0.2*rng.normal(0, 1, size=200)  # 상관 유도

    # 평균 0으로 센터링
    X_centered = X - X.mean(axis=0, keepdims=True)

    # 공분산 행렬(2x2): (X^T X)/(N-1)
    Cov = (X_centered.T @ X_centered) / (X_centered.shape[0]-1)

    # 고유분해: Cov v = λ v  (대칭행렬이므로 eigh 사용)
    eigvals, eigvecs = np.linalg.eigh(Cov)
    # 고유값 내림차순 정렬
    idx = np.argsort(eigvals)[::-1]
    eigvals, eigvecs = eigvals[idx], eigvecs[:, idx]

    print("공분산 행렬:\n", Cov)
    print("고유값(내림차순):", eigvals)
    print("첫 번째 주성분(단위벡터):", eigvecs[:, 0])

    # 첫 번째 주성분 방향으로 데이터 투영
    PC1 = X_centered @ eigvecs[:, 0]
    print("PC1 투영의 평균/표준편차:", np.mean(PC1).round(4), np.std(PC1).round(4))
    print("설명: 공분산의 고유벡터가 분산이 가장 큰 방향(주성분)을 나타냅니다.\n")

    # ---------------------------------------------------------------
    print("====================================")
    print("3) 확률·통계 — 로지스틱 회귀 확률 & 크로스엔트로피")
    print("====================================")

    # 시그모이드: 실수 → (0,1) 확률
    def sigmoid(z):
        return 1.0 / (1.0 + np.exp(-z))

    # 이진분류 토이 데이터 (선형 경계 + 잡음)
    X_cls = rng.normal(0, 1, size=(200, 2))
    scores_true = 1.5*X_cls[:, 0] - 0.7*X_cls[:, 1] + 0.3 + rng.normal(0, 0.3, size=200)
    y_cls = (scores_true > 0).astype(np.float32)

    # 초기 파라미터
    W_lr = rng.normal(0, 0.1, size=(2,))  # 가중치
    b_lr = 0.0                             # 바이어스
    lr = 0.2

    # 이진 크로스엔트로피: L = -[ y*log(p) + (1-y)*log(1-p) ]
    def binary_cross_entropy(y_true, y_prob, eps=1e-8):
        y_prob = np.clip(y_prob, eps, 1.0-eps)  # 숫자 안정화
        return -np.mean(y_true*np.log(y_prob) + (1.0-y_true)*np.log(1.0-y_prob))

    # 로지스틱 회귀의 한 스텝 업데이트(경사하강법)
    # p = sigmoid(XW + b)
    # dL/dW = X^T (p - y) / N,  dL/db = mean(p - y)
    def step_logreg(W, b, X, y, lr):
        z = X @ W + b
        p = sigmoid(z)
        N = X.shape[0]
        grad_W = (X.T @ (p - y)) / N
        grad_b = np.mean(p - y)
        W = W - lr * grad_W
        b = b - lr * grad_b
        loss = binary_cross_entropy(y, p)
        return W, b, loss

    losses = []
    for t in range(120):
        W_lr, b_lr, loss = step_logreg(W_lr, b_lr, X_cls, y_cls, lr)
        losses.append(loss)

    probs = sigmoid(X_cls @ W_lr + b_lr)
    preds = (probs > 0.5).astype(np.float32)
    acc = (preds == y_cls).mean()

    print("최종 크로스엔트로피 손실:", round(losses[-1], 4))
    print("토이 데이터 정확도:", round(acc, 3))
    print("설명: 확률 p=σ(XW+b)와 크로스엔트로피 손실을 이용해 분류기를 학습합니다.\n")

    # ---------------------------------------------------------------
    print("====================================")
    print("4) 회귀·추정 — OLS(정규방정식) & 정규분포 MLE")
    print("====================================")

    # 선형 회귀 데이터 (y = 2.5x - 1.2 + 잡음)
    N = 150
    X_reg = rng.uniform(-3, 3, size=(N, 1))
    y_reg = 2.5*X_reg[:, 0] - 1.2 + rng.normal(0, 0.5, size=N)

    # 정규방정식(닫힌해): w = (X^T X)^(-1) X^T y
    X_design = np.c_[np.ones(N), X_reg]  # [1, x] (바이어스 포함)
    w_hat = np.linalg.inv(X_design.T @ X_design) @ X_design.T @ y_reg  # [b, w]
    b_hat, w1_hat = w_hat[0], w_hat[1]

    # 경사하강법으로도 비교 (반복적, 수치적 방법)
    w_gd, b_gd = 0.0, 0.0
    lr = 0.05
    for _ in range(200):
        y_hat = w_gd*X_reg[:, 0] + b_gd
        residual = y_reg - y_hat
        dL_dw = -2.0/N * np.sum(X_reg[:, 0] * residual)
        dL_db = -2.0/N * np.sum(residual)
        w_gd -= lr * dL_dw
        b_gd -= lr * dL_db

    print(f"OLS(닫힌해)   → w={w1_hat:.3f}, b={b_hat:.3f}")
    print(f"경사하강법    → w={w_gd:.3f}, b={b_gd:.3f}")
    print("설명: OLS는 해석적(닫힌) 해, GD는 반복적 수치 최적화 방법입니다.\n")

    # 정규분포 MLE: x_i ~ N(μ, σ^2) 가정 시
    # μ의 MLE = 표본평균,  σ^2의 MLE = (1/N) * Σ(x_i - μ)^2
    X_gauss = rng.normal(loc=1.7, scale=0.9, size=500)
    mu_mle = np.mean(X_gauss)
    sigma2_mle = np.mean((X_gauss - mu_mle)**2)  # MLE는 1/N (편향보정 아님)

    print(f"Gaussian MLE → μ^={mu_mle:.3f}, σ^2^={sigma2_mle:.3f}")
    print("설명: 우도(likelihood)를 최대화하여 μ, σ^2의 추정치를 얻습니다.\n")

    # ---------------------------------------------------------------
    print("====================================")
    print("5) 이산수학·그래프 — 메시지 패싱(GNN 유사)")
    print("====================================")

    # 4개 노드의 무방향 그래프(체인): 0-1-2-3
    A = np.array([
        [0, 1, 0, 0],
        [1, 0, 1, 0],
        [0, 1, 0, 1],
        [0, 0, 1, 0]
    ], dtype=float)

    # 각 노드의 초기 특징(2차원)
    H0 = np.array([
        [1.0, 0.0],   # node 0
        [0.0, 1.0],   # node 1
        [1.0, 1.0],   # node 2
        [0.5, 0.2],   # node 3
    ])

    # 차수행렬 D (대각원소: 노드별 이웃 수)
    D = np.diag(A.sum(axis=1))

    # 행정규화 인접행렬: A_norm = D^{-1} A  (이웃 평균)
    D_inv = np.linalg.inv(D)           # 체인 그래프라 0차수 노드 없음 → 역행렬 존재
    A_norm = D_inv @ A

    # 메시지 패싱 한 단계: H' = ReLU( A_norm @ H @ W )
    W_msg = np.array([[1.0, -0.5],
                      [0.2,  0.8]])    # 실제 GNN에서는 학습되는 가중치
    def relu(z): return np.maximum(0.0, z)

    H1 = relu(A_norm @ H0 @ W_msg)     # 1층 메시지 패싱
    H2 = relu(A_norm @ H1 @ W_msg)     # 2층 메시지 패싱

    print("A (인접행렬):\n", A)
    print("A_norm (행정규화):\n", A_norm)
    print("H0 (초기 특징):\n", H0)
    print("H1 (1단계 후):\n", H1)
    print("H2 (2단계 후):\n", H2)
    print("설명: 각 단계에서 이웃의 특징을 평균한 뒤 선형변환+비선형 함수를 적용합니다.\n")

if __name__ == "__main__":
    main()