페이지

2022년 7월 22일 금요일

2.4.1 변화율이란?

 실수 x를 새로운 실수 y로 매핑하는 연속적이고 매끄러운 함수 f(x) = y를 생각해 봅시다. 이 함수가 연속적이므로 x를 조금 바꾸면 y가 조금만 변경될 것입니다. 이것이 연속성의 개념입니다. x를 작은 값 epsilon_x만큼 증가시켰을 때 y가 epsilon_y만큼 바뀐다고 말할 수 있습니다.

f(x +epsilon_x) = y + epsilon_y

또 이 함수가 매끈하므로(곡선의 각도가 갑자기 바뀌지 않습니다) epsilon_x가 충분히 작다면 어떤 포인트 p 에서 기울기 a의 선형 함수로 f를 근사할 수 있습니다. 따라서 epsilon_y는 a * epsilon_x가 됩니다.

f(x +epsilon_x) = y +  a * epsilon_x

이 선형적인 근사는 x 가 p에 충분히 가까울 때 유효합니다.

이 기울기를 p에서 f의 변환율(derivative)이라고 합니다. 이는 a가 음수일 때 p에서 양수 x만큼 조금 이동하면 f(x)가 감소한다는 것을 의미합니다. a가 양수일 때는 음수 x만큼 조금 이동하면 f(x)가 감소됩니다. a 의 절댓값(변환율의 크기)은 이런 증가나 감소가 얼마나 빠르게 일어날지 알려 줍니다.

모든 미분 가능한(미분 가능하다는 것은 변화율을 유도할 수 있다는 의미로, 예를 들어 매끄럽고 연속적인 함수입니다) 함수 f(x)에 대해 x의 값을 f의 국부적인 선형 극사인 그 지점의 기울기로 매핑하는 변화율 함수 f'(x)가 존재합니다. 예를 들어 cos(x)의 변화율은 -sin(x)이고, f(x) = a * x의 변화율은 f'(x)=a입니다.

f(x)를 최소화하기 위해 epsilon_x만큼 x 를 업데이트 하고 싶을 때 f의 변환유ㅜㄹ을 알고 있으면 해결됩니다. 변화율 함수는 x가 바뀜에 따라 f(x)가 어떻게 바뀔지 설명해 줍니다. f(x)의 값을 감소시키고 싶다면 x를 변화율의 방향과 반대로 조금 이동해야 합니다.

2.4 신경망의 엔진: 그래디언트 기반 최적화

 이전 절에서 보았듯이 첫 번째 신경망 예제에 있는 각 층은 입력 데이터를 다음과 같이 변환합니다.

output = relu(dot(W, input) + b)

이 식에서 턴서 W와 b는 층의 속성 처럼 볼 수 있습니다. 가중치(weight)또는 훈련되는 파라미터(trainable parameter)라고 부릅니다(각각 커널(kernel)과 편향(bias)이라고 부르기도 합니다). 이런 가중치에는 훈련 데이터를 신경망에 노출시켜서 학습된 정보가 담겨 있습니다.

초기에는 가중치 행렬이 작은 난수로 채워져 있습니다.(무작위 초기화(random initialization)단계라고 부릅니다). 물론 W와  b가 난수일 때 relu(dot(W, input) + b)가 유용한 어떤 표현을 만들 것이라고 기대할 수는 없습니다. 즉 의미 없는 표현이 만들어집니다. 하지만 이는 시작 단계일뿐입니다. 그 다음에는 피드백 신호에 기초하여 가중치가 점진적으로 저정될 것입니다. 이런 점진적인 조정 또는 훈련(trainintg)이 머신 러닝 학습의 핵심입니다.

훈련은 다음과 같은 훈련 반복 루프(training loop)안에서 일어납니다. 필요한 만큼 반복 푸프안에서 이런 단계가 반복됩니다.

1. 훈련 샘플 x와 이에 상응하는 타깃 y의 배치를 추출합니다.

2. x를 사용하여 네트워크를 실행하고(정방향 패스(forward pass) 단계), 예측 y_pred를 구합니다.

3. y_pred와 y의 차이를 측정하여 이배치에 대한 네트워크의 손실을 계산합니다.

4. 배치에 대한 손신이 조금 감소되도록 네트워크의 모든 가중치를 업데이트 합니다.

결국 훈련 데이터에서 네트워크의 손실, 즉 예측 y_pred와 타깃 y의 오차가 매우 작아질 것입니다. 이 네트워크는 입력에 정확한 타깃을 매핑하는 것을 학습했습니다. 전체적으로 보면 마술처럼 보이지만 개별적인 단계로 쪼개서 보면 단순합니다.

1단계는 그먕 입출력 코드이므로 매우 쉽습니다. 2단계와 3단계는 몇 개의 텐서 연산을 적용한 것뿐이므로 이전 절에서 배웠던 연산을 사용하여 이 단계를 구현할 수 있습니다. 어려운 부분은 네트워크의 가중치를 업데이트하는 4단계입니다. 개별적인 가중치 값이 있을 때 값이 증가해야 할지 감소해야 할지, 또 얼마큼 업데이트해야 할지 어떻게ㅐ 알 수 있을까요?

한 가지 간단한 방법은 네트워크 가중치 행렬의 원소를 모두 고정하고 관심 있는 하나만 다른 값을 적용해 보는 것입니다. 이 가중체의 초깃값이 0.3이라고 가정합니다. 배치 데이터를 정방향 패스에 통과시킨 후 네트워크의 손실이 0.5가 나왔습니다. 이 가증치 값을 0.35로 변경하고 다시 정방향 패스를 실행했더니 손실이 0.6으로 증가했습니다. 반대로 0.25로 줄이면 손실이 0.4로 감소했습니다. 이경우에 가중치를 0.05만큼 업데이트한 것이 손실을 줄이는데 기여한 것으로 보입니다. 이런 식으로 네트워크의 모든 가중치에 반복합니다.

이런 접근 방식은 모든 가중치 행렬의 원소마다 두 번의 (비용이 큰) 정방향 패스를 계산해야 하므로 엄청나게 비효율적입니다(보통 수천에서 경우에 따라 수백만 개의 많은 가중치가 있습니다). 신경망에 사용된 모든 연산이 미분 가능(differentiable)하다는 장점을 사용하여 네트워크 가중치에 대한 손실의 그래디언트(gradient)를 계산하는 것이 훨씬 더 좋은 방법입니다. 그래디언트의 반대 방향으로 가중치를 이동하면 손실이 감소됩니다.

미분 가능하다는 것과 그래디언트가 무엇인지 이미 알고 있다면 2.4.3절로 건너 뛰어도 좋습니다. 그렇지 않으면 다음 두 절이 이해하는데 도움이 될 것입니다.

2.3.6 딥러닝의 기하학적 해석

 신경망은 전체적으로 텐서 연산의 연결로 구성된 것이고, 모든 텐서 연산은 입력 데이터의 기하학적 변환임을 배웠습니다. 단순한 단계들이 길게 이어져 구현된 신경망을 고차원 공간에서 매우 복잡한 기하학적 변환을 하는 것으로 해석할 수 있습니다.

3D라면 다음 비유가 이해하는 데 도움이 될 것입니다. 하나는 빨간색이고 다른 하나는 파란색인 2개의 색종이가 있다고 가정합시다. 두 장을 겹친 다음 뭉쳐서 작은 공으로 만듭니다. 이 종이 공이 입력 데이터고 색종이는 분류 문제의 데이터 클래스입니다. 신경망(또는 다른 머신 러닝 알고리즘)이 해야 할 일은 종이 공을 펼쳐서 두 클래스가 다시 깔끔하게 분리되는 변환을 찾는 것입니다. 손가락으로 종이 공을 조금씩 펼치는 것처럼 딥러닝을 사용하여 3D 공간에서 간단한 변환들을 연결해서 이를 구현합니다.

종이 공을 펼치는 것이 머신 러닝이 하는 일입니다. 복잡하고 심하게 꼬여 있는 데이터의 매니플드에 대한 깔끔한 표현을 찾는 일입니다. 이쯤이면 왜 딥러닝이 이런 작업에 뛰어난지 알았을 것입니다. 기초적인 연산을 길게 연결하여 복잡한 기하학적 변환을 조금씩 분해하는 방식이 마치 사람이 종이 공을 펼치기 위한 적략과 매우 흡사하기 때문입니다.

심층 네트워크의 각 층은 데이터를 조금씩 풀어 주는 변환을 적용하므로, 이런 층을 깊게 쌓으면 아주 복잡한 분해 과정을 처리할 수 있습니다.

2.3.5 텐서 연산의 기하학적 해석

 텐서 연산이 조작하는 텐서의 내용은 어떤 기하학적 공간에 있는 좌표 포인트로 해석될 수 있기 때문에 모든 텐서 연산은 기하학적 해석이 간으합니다. 예를 들어 덧셈을 생각해 보죠. 다음 벡터를 먼저 보겠습니다.

A = [0.5, 1]

이 포인트는 2D 공간에 있습니다. 일반적으로 원점에서 포인트를 연결하는 화살표로 벡터를 나타냅니다.

새로운 포인트 B = [1, 0.25]를 이전 벡터에 더해 보겠습니다. 기하학적으로는 벡터 화살표를 연결하여 개산할 수 있습니다. 최종 위치는 두 벡터의 덧셈을 나타내는 벡터가 됩니다.

일반적으로 아핀 변환(affine transformation), 회전, 스케일링(scaling) 등처럼 깆본적인 기하학적 연산은 턴서 연산으로 표현될 수 있습니다. 예를 들어 theta 각도로 2D 벡터를 회전하는 것은 2 * 2행렬 R = [u, v]를 점곱하여 구현할 수 있습니다. 여기에서 u, v는 동일 평면상의 벡터이면 u = [cos(theta), sin(theta)] 고 v = [-sin(theta), cos(theta)] 입니다.

2.3.4 텐서 크기 변환

 꼭 알아 두어야 할 세 번째 턴서 연산은 턴스 크기 변환(tensor reshaping)입니다. 첫 번째 신경망 예제의 Dense 층에서는 사용되지 않지만 신경망에 주입할 숫자 데이터를 전처리할 때 사용했습니다.

train_images = train_images.reshape((60000, 28 * 28))

텐서의 크기를 변환한다는 것은 특정 크기에 맞게 열과 행을 재배열한다는 뜻입니다. 당연히 크기가 변환된 텐서는 원래 턴서와 원소 갯수가 동일합니다. 간단한 예제를 통해 크기 변환을 알아보겠습니다.

>>> x = np.array([[0., 1.],

                        [2., 3.],

                        [4., 5.]])

>>> print(x.shape)

(3, 2)

>>> x = x .reshape((6, 1))

>>> x

array([[0.],

        [1.],

        [2.],

        [3.],

        [4.],

        [5.]])

>>> x = x.reshape((2, 3))

>>> x

array([[0., 1., 2.],

        [3., 4., 5.]])

자주 사용하는 특별한 크기 변환은 전치(transposition)입니다. 행렬의 전치는 행과 열을 바꾸는 것을 의미합니다. 즉 x[i, :]이 x[:, i]가 됩니다.

>>> x = np.zeros((300, 20)) .................. 모두 0으로 채워진 (300, 20) 크기의 행렬을 만듭니다.

>>> x = np.transpose(x)

>>> print(x.shape)

(20, 300)

    

2.3.3 텐서 점곱

 텐서 곱셈(tensor product)이라고도 부르는 (원소별 곱셈과 혼동하지 마세요) 점곱 연산(dot operation)은 가장 널리 사용되고 유용한 텐서 연산입니다. 원소별 연산과 반대로 입련 텐서의 원소들을 결합시킵니다.

넘파이, 케라스, 씨아노, 텐서플로에서 원소별 곱셈은 * 연산자를 사용합니다. 텐서플로에서는 dot 연산자가 다르지만 넘파이와 케라스는 점곱 연산에 보편적인 dot연산자를 사용합니다.

    import numpy as np

    z = np.dot(x, y)

    z = x . y

점곱 연산은 수학에서 어떤일을 할까요? 2개의 벡터 x 와 y의 점곱은 다음과 같이 계산을 합니다.

def naive_vector_dot(x, y):

   assert len(x.shape) == 1........... x는 넘파이 벡터입니다.

   assert len(y.shape) == 1........... y는 넘파이 벡터입니다.

    z = 0.

    for i in range(x.shape[0]):

        z += x[i] * y[i]

    return z

여기서 볼 수 있듯이 두 벡터의 점곱은 스칼라가 되므로 원소 개수가 같은 벡터끼리 점곱이 가능합니다.

행렬 x와 벡터 y사이에서도 점곱이 가능합니다. y와  x의 행 사이에서 점곱이 일어나므로 벡터가 반환됩니다. 다음과 같이 구현할 수 있습니다.

    import numpy as np

    def naive_matrix_vector_dot(x, y)

        assert len(x.shape) == 2 .......... x는 넘파이 행렬입니다.

        assert len(y.shape) == 1............ y는 넘파이 벡터입니다.

        assert x.shape[1] == y.shape[0] ......... x의 두 번째 차원이  y의 첫번째 차원과 같아야 합니다.

        z = np.zeros(x.shape[0]) .....이 연산은 x의 행과 같은 크기의  0이 채워진 벡터를 만듭니다.

        for i in range(x.shape[0]):

            for j in range(x.shape[1]):

                z[i] += x[i,j] * y[j]

        return z

행렬-벡터 점곱과 벡터-벡터 점곱 사이의 관계를 부각하기 위해 앞에ㅐ서 만든 함수르 재사용하겠습니다.

    def naive_matrix_vector_dot(x,y):

        z = np.zeros(x.shape[0])

        for i in range(x.shape[0])

            z[i] = naive_vector_dot(x[i, :],  y)

        return z

두 텐서 중 하나라도 ndim이 1보다 크면 dot 연산에 교환 법칙이 성립되지 않습니다. 다시 말하면 dot(x, y)와 dot(y, x)가 같지 않습니다.

물론 점곱은 임의의 축 개수를 가진 텐서에 일반화됩니다. 가장 일반적인 용도는 두 행렬 간의 점곱일 것입니다. x.shape[1] == y.shape[0]일 때 두 행렬 x와 y의 점곱(dot(x, y))이 성립됩니다. x의 행과 y의 열 사이 백터 점곱으로 인해(x.shape[0], y.shape[1]) 크기의 행렬이 됩니다. 다음은 단순한 구현 예 입니다.

def naive_matrix_dot(x, y):

    assert len(x.shape) == 2 .......... x는 넘파이 행렬입니다.

    assert len(y.shape) == 2 .......... y는 넘파이 행렬입니다.

    assert x.shape[1] == y.shape[0]   ........ x 의 두 번째 차원이 y의 첫번째 차원과 같아야 합니다!

    z = np.zeros((x.shape[0], y.shape[1])) ........ 이 연산은 0이 채워진 특정 크기의 벡터를 만듭니다.

    for i in range (x.shape[0]):   ...... x의 행을 반복합니다.

        for j in range(y.shape[1]): ..... y의 열을 반복합니다.

             row_x = x[i, :]

            column_y = y[:, j]

            z[i, j] = naive_vector_dot(row_x, column_y)

    return z

그림 2-5 와 같이 입력과 출력을 배치해 보면 어떤 크기의 점곱이 가능하지 이해하는데 도움이 됩니다.

x, y, z는 직사각형 모양으로 그려져 있습니다(원소들이 채워진 박스라고 생각하면 됩니다). x의 행 벡터와 y의 열 벡터가 같은 크기여야 하므로 자동으로 x의 너비는 y의 놉이와 동일해야 합니다. 새로운 머신 러닝 알고리즘을 개발할 때 이런 그림을 자주 그리게 될 것 입니다.

더 일반적으로 앞서 설명한 2D의 경우 처럼 크기를 맞추는 동일한 규칙을 따르면 다음과 같이 고차원 텐서 간의 점곱을 할 수 있습니다.

(a, b, c, d) . (d,) -> (a, b, c)

(a, b, c, d) . (d, e) -> (a , b, c, e)




2022년 7월 21일 목요일

2.3.2 브로드캐스팅

 앞서 살펴본 단순한 덧셈 구현인 naive_add는 동일한 크기의 2D 텐서만 지원합니다. 하지만 이전에 보았던 Dense 층에서는 2D 텐서와 벡터를 더했습니다. 크기가 다른 두 텐서가 더해질 때 무슨일이 일어날까요?

모호하지 않고 실행 가능하다면 작은 텐서가 큰 텐서의 크기에 맞추어 브로드캐스팅(broadcasting)됩니다. 브로드캐스팅은 두 단계로 이루어집니다.

1. 큰 텐서의 ndim에 맞도록 작은 텐서에(브로드캐스팅 축이라고 부르는) 축이 추가 됩니다.

2. 작은 텐서가 새 축을 따라서 큰 텐서의 크기에 맞도록 반복됩니다.

구체적인 옐르 살펴보겠습니다.

x의 크기는 (32, 10)이고 y의 크기는 (10,)라고 가정합시다. 먼저 y에 비어 있는 첫 번째 축을 추가하여 크기를 (1, 10)으로 만듭니다. 그런 다음 y를 이 축에 32번 반복하면 텐서 Y의 크기는 (32, 10)이 됩니다. 여기에는 Y[i, :] == y for i in range(0, 32)입니다. 이제 X와 Y의 크기가 같으므로 더할 수 있습니다.

구현 입장에서는 새로운 텐서가 만들어지면 매우 비효율적이므로 어떤 2D 텐서도 만들어지지 않습니다. 반복된 연산은 완전히 가상적입니다. 이 과정은 메모리 수준이 아니라 알고리즘 수준에서 일어납니다. 하지만 새로운 축을 따라 벡커가 32번 반복된다고 생각하는 것이 이해하기 쉽습니다. 다음은 단순하게 구현한 예입니다.

 def naive_add_matrixa_and_vector(x, y):

    assert len(x.shape) == 2 ..... x 는 2D 넘파이 배열입니다.

    assert len(y.shape) == 1 .... y는 넘파이 벡터입니다.

    assert x.shape[1] == y.shape[0]


    x = x.copy() ....... 입력 텐서 자체를 바꾸지 않도록 복사합니다.

    for i in range(x.shape[0]):

        for j in range(x.shape[1]):

            x[i, j] += y[j]

    return x

(a, b, ... n, n + 1, .... m) 크기의 텐서와 (n, n + 1, ,..... m) 크기의 텐서 사이에 브로드 캐스팅으로 원소별 연산을 적용할 수 있습니다. 이때 브로드캐스팅은 a부터 n - 1까지의 축에 자동으로 일어납니다.

다음은 크기가 다른 두 텐서에 브로드 캐스팅으로 원소별 maximum 연산을 적용하는 예입니다.

    import numpy as np

    x = np.random.random((64, 3, 32, 10)) ..... x 는 (64, 3,32, 10)크기의 랜덤 텐서입니다.

    y = np.random.random((32, 10)) .....  y 는 (32, 10) 크기의 랜덤 텐서입니다.


    z = np.maximum(x, y) .... 출력 z크기는 x와 동일하게 (64, 3, 32, 10)입니다.