AI의 수학: 간단하게 시작해봅시다

100 이하의 두 수를 더하는 프로그램을 보통 어떻게 작성하나요? 쉽죠? 일반적으로는 다음과 같이 간단하게 작성할 것입니다:

#include <iostream>
using namespace std;

int main() {
    int a, b, sum;

    cout << "첫 번째 숫자를 입력하세요: ";
    cin >> a;

    cout << "두 번째 숫자를 입력하세요: ";
    cin >> b;

    sum = a + b;

    cout << "합계는: " << sum << endl;

    return 0;
}

하지만 좀 더 흥미로운 방법을 시도해봅시다—기계가 스스로 덧셈을 배우도록 신경망을 사용해보겠습니다. 신경망은 현대 AI의 핵심으로, 이미지 인식과 언어 번역에서부터 ChatGPT와 같은 고급 시스템까지 모든 것을 지원합니다.

이 글에서는 신경망이 어떻게 "학습"하여 두 수를 더하는지를 보여줄 것입니다. 이는 하드코딩된 규칙을 따르지 않고 훈련을 통해 이루어집니다. 신경망이 패턴을 인식하여 "더하기"를 배우는 과정을 보게 될 것이며, 이 과정에서 실제 숫자(커튼 뒤의 마법)를 공개할 것입니다.

훈련된 신경망의 내부 구조를 살펴보겠습니다:

// 숨겨진 층의 가중치와 편향
double weight1_node1 = 0.658136;
double weight2_node1 = 0.840666;
double bias_node1 = -0.893218;

double weight1_node2 = -0.720667;
double weight2_node2 = -0.369172;
double bias_node2 = 0.036762;

double weight1_node3 = 0.512252;
double weight2_node3 = 0.292342;
double bias_node3 = -0.0745917;

// 출력 층의 가중치와 편향
double weight_out_node1 = 1.93108;
double weight_out_node2 = -0.718584;
double weight_out_node3 = 0.589741;
double bias_out = -0.467899;

처음에는 이 값들이 무작위로 보일 수 있지만, 이들은 두 수를 더하는 방법을 학습할 수 있는 작은 인공지능 뇌를 나타냅니다.

출력을 계산하기 위해 이 공식을 사용하세요. 지금은 복잡해 보일 수 있지만, 따라 해보세요:

여기서, x1 과 x2 는 더하고자 하는 두 수입니다 (100으로 나누어 정규화됨):

double x1 = 0.3; // 30 / 100
double x2 = 0.5; // 50 / 100

// 숨겨진 층
double z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
double h1 = sigmoid(z1);

double z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
double h2 = sigmoid(z2);
double z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
double h3 = sigmoid(z3);

// 출력 층
double sum = (h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out) * 200.0f;

왜 200을 곱하는지 설명해봅시다: 우리는 처음에 입력과 출력을 0과 1 사이로 정규화하여 더 쉽게 훈련했습니다. 100 이하의 두 수의 최대 합은 200이므로, 출력을 다시 200으로 곱하여 스케일을 맞춥니다.

시그모이드 함수:

double sigmoid(double x) {
    return 1.0 / (1.0 + exp(-x));
}  

이제 실제 숫자로 시도해봅시다:

이제 x1 = 30 과 x2 = 50 를 사용해 보겠습니다. 먼저 정규화합니다:

x1 = 30 / 100 = 0.3
x2 = 50 / 100 = 0.5

이제 수학을 진행해봅시다:

z1 = (0.3 × 0.658136) + (0.5 × 0.840666) - 0.893218
   ≈ 0.1974408 + 0.420333 - 0.893218
   ≈ -0.2754442
h1 = sigmoid(z1) ≈ 0.431844

z2 = (0.3 × -0.720667) + (0.5 × -0.369172) + 0.036762
   ≈ -0.2162001 - 0.184586 + 0.036762
   ≈ -0.3640241
h2 = sigmoid(z2) ≈ 0.409872

z3 = (0.3 × 0.512252) + (0.5 × 0.292342) - 0.0745917
   ≈ 0.1536756 + 0.146171 - 0.0745917
   ≈ 0.2252549
h3 = sigmoid(z3) ≈ 0.556078

// 최종 출력 층
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.834389) + (-0.294320) + (0.328013) - 0.467899
       ≈ 0.400183

예측된 합계 = 0.400183 × 200 ≈ 80.0366
// 왜 200을 곱하나요? 훈련 중에 모든 입력과 출력을 0과 1 사이로 정규화하여 학습 안정성을 높였습니다. 두 입력의 최대 합은 100 + 100 = 200이므로, 네트워크의 출력을 다시 200으로 곱하여 스케일을 맞춥니다.

우와! 결과는 약 80으로, 30과 50의 정확한 합계입니다.

이 작은 네트워크가 정말로 더할 수 있다고 믿지 않으신다면, 다른 숫자로 다시 테스트해봅시다: 20 과 30.

먼저 입력을 정규화합니다:

x1 = 20 / 100 = 0.2
x2 = 30 / 100 = 0.3

그 다음 계산을 진행합니다:

z1 = (0.2 × 0.658136) + (0.3 × 0.840666) - 0.893218
   ≈ 0.1316272 + 0.2521998 - 0.893218
   ≈ -0.509391
h1 = sigmoid(z1) ≈ 0.375241

z2 = (0.2 × -0.720667) + (0.3 × -0.369172) + 0.036762
   ≈ -0.1441334 - 0.1107516 + 0.036762
   ≈ -0.218123
h2 = sigmoid(z2) ≈ 0.445714

z3 = (0.2 × 0.512252) + (0.3 × 0.292342) - 0.0745917
   ≈ 0.1024504 + 0.0877026 - 0.0745917
   ≈ 0.1155613
h3 = sigmoid(z3) ≈ 0.528860

// 최종 출력 층
output = (h1 × 1.93108) + (h2 × -0.718584) + (h3 × 0.589741) - 0.467899
       ≈ (0.724688) + (-0.320095) + (0.311644) - 0.467899
       ≈ 0.248338

예측된 합계 = 0.248338 × 200 ≈ 49.6676

약 49.67을 예측했습니다. 실제 합계인 50에 매우 가깝습니다!

정말 멋지죠?

그럼... 신경망이란 도대체 무엇인가요?

간단한 소개: 신경망은 당신의 뇌의 수학적 버전과 같습니다. 입력을 가중치로 곱하고 편향을 더한 후 활성화 함수를 적용하는 뉴런이라는 단위로 구성됩니다. 일반적인 활성화 함수는 시그모이드입니다:

sigmoid(x) = 1 / (1 + e^(-x))

이 함수는 모든 입력을 0과 1 사이의 값으로 압축하여 네트워크가 복잡한 패턴을 포착할 수 있도록 합니다.

이것은 시그모이드 함수의 그래프이며, 입력이 어떻게 부드럽게 0과 1 사이의 값으로 변환되는지를 명확하게 보여줍니다:

ReLU (Rectified Linear Unit), tanh, softmax와 같은 다른 활성화 함수도 있지만, 각각의 사용 사례와 동작 방식이 다릅니다. 하지만 시그모이드는 간단하고 부드러워서 우리와 같은 작은 신경망에 적합한 시작점입니다.

설정: 우리의 네트워크에 덧셈을 가르치기

이 실험을 위해 우리는 다음과 같은 작은 네트워크를 만들었습니다:

  • 2개의 입력 노드 (더할 두 수를 위해)
  • 3개의 숨겨진 노드 (시그모이드 마법으로 무거운 작업을 수행)
  • 1개의 출력 노드 (합계를 제공합니다)

여기 두 수를 더하기 위해 사용하는 신경망의 구조를 보여주는 다이어그램이 있습니다. 각 입력 노드가 모든 숨겨진 층 노드에 어떻게 연결되는지, 가중치와 편향이 어떻게 적용되는지, 그리고 모든 것이 최종 출력으로 흐르는지를 볼 수 있습니다:

그리고 네, 우리는 모든 것을 스케일 조정했습니다. 우리의 숫자가 100 이하이기 때문에, 입력을 100으로 나누고 마지막에 출력을 200으로 다시 스케일 조정합니다.

신경망을 사용하여 두 수를 더하기 위해 작성한 코드는 다음과 같습니다:

#include <iostream>
#include <cmath>
using namespace std;

float sigmoid(float x) {
    return 1.0f / (1.0f + exp(-x));
}

float sigmoid_derivative(float y) {
    return y * (1 - y); // y = sigmoid(x)
}

int main() {
    float weight1_node1 = 0.5f, weight2_node1 = 0.5f, bias_node1 = 0.0f;
    float weight1_node2 = -0.5f, weight2_node2 = -0.5f, bias_node2 = 0.0f;
    float weight1_node3 = 0.25f, weight2_node3 = -0.25f, bias_node3 = 0.0f;

    float weight_out_node1 = 0.5f, weight_out_node2 = 0.5f, weight_out_node3 = 0.5f, bias_out = 0.0f;

    float learning_rate = 0.01f;
    int epochs = 1000;

    for (int epoch = 0; epoch < epochs; ++epoch) {
        float total_loss = 0.0f;
        for (int a = 0; a < 100; ++a) {
            for (int b = 0; b < 100; ++b) {
                float x1 = a / 100.0f;
                float x2 = b / 100.0f;
                float target = (a + b) / 200.0f; // Normalize target to 0-1 range

                // Forward pass for hidden layer
                float z1 = x1 * weight1_node1 + x2 * weight2_node1 + bias_node1;
                float h1 = sigmoid(z1);

                float z2 = x1 * weight1_node2 + x2 * weight2_node2 + bias_node2;
                float h2 = sigmoid(z2);

                float z3 = x1 * weight1_node3 + x2 * weight2_node3 + bias_node3;
                float h3 = sigmoid(z3);

                // Output layer with linear activation
                float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;

                float error = target - y_pred;
                total_loss += error * error;

                // Backward pass (output layer - linear)
                float delta_out = error;
                float grad_out_w1 = delta_out * h1;
                float grad_out_w2 = delta_out * h2;
                float grad_out_w3 = delta_out * h3;
                float grad_out_b = delta_out;

                // Backward pass (hidden layer)
                float delta_h1 = delta_out * weight_out_node1 * sigmoid_derivative(h1);
                float delta_h2 = delta_out * weight_out_node2 * sigmoid_derivative(h2);
                float delta_h3 = delta_out * weight_out_node3 * sigmoid_derivative(h3);

                float grad_w1_node1 = delta_h1 * x1;
                float grad_w2_node1 = delta_h1 * x2;
                float grad_b_node1 = delta_h1;

                float grad_w1_node2 = delta_h2 * x1;
                float grad_w2_node2 = delta_h2 * x2;
                float grad_b_node2 = delta_h2;

                float grad_w1_node3 = delta_h3 * x1;
                float grad_w2_node3 = delta_h3 * x2;
                float grad_b_node3 = delta_h3;

                // Update weights
                weight_out_node1 += learning_rate * grad_out_w1;
                weight_out_node2 += learning_rate * grad_out_w2;
                weight_out_node3 += learning_rate * grad_out_w3;
                bias_out += learning_rate * grad_out_b;

                weight1_node1 += learning_rate * grad_w1_node1;
                weight2_node1 += learning_rate * grad_w2_node1;
                bias_node1 += learning_rate * grad_b_node1;

                weight1_node2 += learning_rate * grad_w1_node2;
                weight2_node2 += learning_rate * grad_w2_node2;
                bias_node2 += learning_rate * grad_b_node2;

                weight1_node3 += learning_rate * grad_w1_node3;
                weight2_node3 += learning_rate * grad_w2_node3;
                bias_node3 += learning_rate * grad_b_node3;
            }
        }

        if ((epoch + 1) % 100 == 0 || epoch == 0)
            cout << "[요약] 에폭 " << epoch + 1 << ": 손실 = " << total_loss << endl;
    }

    // 모델 테스트
    float a, b;
    cout << "\n합계 예측 (a + b)\n a를 입력하세요: ";
    cin >> a;
    cout << "b를 입력하세요: ";
    cin >> b;

    float x1 = a / 100.0f;
    float x2 = b / 100.0f;

    float h1 = sigmoid(x1 * weight1_node1 + x2 * weight2_node1 + bias_node1);
    float h2 = sigmoid(x1 * weight1_node2 + x2 * weight2_node2 + bias_node2);
    float h3 = sigmoid(x1 * weight1_node3 + x2 * weight2_node3 + bias_node3);
    float y_pred = h1 * weight_out_node1 + h2 * weight_out_node2 + h3 * weight_out_node3 + bias_out;
    float predicted_sum = y_pred * 200.0f;

    cout << "w1_node1: " << weight1_node1 << ", w2_node1: " << weight2_node1
                             << ", w1_node2: " << weight1_node2 << ", w2_node2: " << weight2_node2
                             << ", w1_node3: " << weight1_node3 << ", w2_node3: " << weight2_node3
                             << ", out_w1: " << weight_out_node1 << ", out_w2: " << weight_out_node2
                             << ", out_w3: " << weight_out_node3 << ", bias_out: " << bias_out
                             << ", bias_node1: " << bias_node1 << ", bias_node2: " << bias_node2 << ", bias_node3: " << bias_node3 << endl;

    cout << "예측된 합계: " << predicted_sum << "\n실제 합계: " << (a + b) << endl;

    return 0;
}

그럼... 어떻게 학습했을까요?

여기서 멋진 부분이 있습니다: 우리는 덧셈 규칙을 코딩하지 않았습니다. 우리는 네트워크에 숫자 쌍과 그 합계를 제공했으며, 네트워크는 예측이 점점 더 좋아지도록 가중치와 편향을 천천히 조정했습니다.

이건 일종의 시행착오와 비슷하지만 더 똑똑합니다—미적분의 한 스푼을 더한 것과 같죠.

정말로 더하는 법을 배웠나요?

음, 우리가 하는 것과는 정확히 같지는 않습니다. "더하기"가 무엇인지 이해하지 못합니다. 하지만 아주 좋은 근사값을 배웁니다. 숫자에서 패턴을 보고 종종 정확한 합계를 예측합니다.

규칙을 아는 것보다 패턴을 느끼며 배우는 것에 가깝습니다. 궁금하시다면 입력 숫자를 변경하고 테스트해보세요. 많이 해볼수록 이 작은 뇌들이 어떻게 작동하는지 더 잘 이해하게 될 것입니다.

마무리

신경망에게 두 수를 더하는 법을 가르치는 것은 처음에는 어리석게 들릴 수 있지만, AI의 마음을 들여다볼 수 있는 좋은 방법입니다. 모든 과대광고 뒤에는 단지 가중치, 편향, 그리고 똑똑한 수학이 있을 뿐입니다.

다음 부분에서는 신경망이 어떻게 작동하는지 더 깊이 파고들 것입니다: 층, 활성화 함수, 미분, 그리고 훈련 중에 실제로 무슨 일이 일어나는지를 탐구할 것입니다.