2.3 신경망의 톱니바퀴: 텐서 연산

2.3 신경망의 톱니바퀴: 텐서 연산 | 목차 | 2.4 신경망의 엔진: 그래디언트 기반 최적화

 

컴퓨터 프로그램을 이진수의 입력을 처리하는 몇 개의 이항 연산(AND, OR, NOR 등)으로 표현할 수 있는 것처럼, 심층 신경망이 학습한 모든 변환을 수치 데이터 텐서에 적용하는 몇 종류의 텐서 연산tensor operation으로 나타낼 수 있습니다. 예를 들어 텐서 덧셈이나 텐서 곱셈 등입니다.

첫 번째 예제에서는 Dense 층을 쌓아서 신경망을 만들었습니다. 케라스의 층은 다음과 같이 생성 합니다.

keras.layers.Dense(512, activation='relu')

이 층은 2D 텐서를 입력으로 받고 입력 텐서의 새로운 표현인 또 다른 2D 텐서를 반환하는 함수처럼 해석할 수 있습니다. 구체적으로 보면 이 함수는 다음과 같습니다(W는 2D 텐서고, b는 벡터입니다. 둘 모두 층의 속성입니다17).

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

좀 더 자세히 알아보겠습니다. 여기에는 3개의 텐서 연산이 있습니다. 입력 텐서와 텐서 W 사이의 점곱(dot), 점곱의 결과인 2D 텐서와 벡터 b 사이의 덧셈(+), 마지막으로 relu(렐루) 연산입니다. relu(x)는 max(x, 0)입니다.18

Note 이 절은 선형대수학(linear algebra)을 다루지만 어떤 수학 기호도 사용하지 않습니다. 수학에 익숙하지 않은 프로그래머는 수학 방정식보다 짧은 파이썬 코드를 보는 것이 수학 개념을 이해하는 데 훨씬 도움이 됩니다. 앞으로도 계속 넘파이 코드를 사용하여 설명합니다.

 

2.3.1 원소별 연산

relu 함수와 덧셈은 원소별 연산element-wise operation입니다. 이 연산은 텐서에 있는 각 원소에 독립적으로 적용됩니다. 이 말은 고도의 병렬 구현(1970~1990년대 슈퍼컴퓨터의 구조인 벡터 프로세서vector processor에서 온 용어인 벡터화된 구현을 말합니다)이 가능한 연산이라는 의미입니다. 파이썬으로 단순한 원소별 연산을 구현한다면 다음 relu 연산 구현처럼 for 반복문을 사용할 것입니다.19

def naive_relu(x):
    assert len(x.shape) == 2    # x는 2D 넘파이 배열입니다.
    x = x.copy()                # 입력 텐서 자체를 바꾸지 않도록 복사합니다.
    for i in range(x.shape[0]):
        for j in range(x.shape[1]):
            x[i, j] = max(x[i, j], 0)
    return x

덧셈도 동일합니다.

def naive_add(x, y):
    assert len(x.shape) == 2     # x와 y는 2D 넘파이 배열입니다.
    assert x.shape == y.shape

    x = x.copy()                 # 입력 텐서 자체를 바꾸지 않도록 복사합니다.
        for i in range(x.shape[0]):
            for j in range(x.shape[1]):
                x[i, j] += y[i, j]
    return x

같은 원리로 원소별 곱셈, 뺄셈 등을 할 수 있습니다.

사실 넘파이 배열을 다룰 때는 최적화된 넘파이 내장 함수로 이런 연산들을 처리할 수 있습니다. 넘파이는 시스템에 설치된 BLASBasic Linear Algebra Subprogram 구현에 복잡한 일들을 위임합니다.20 BLAS는 고도로 병렬화되고 효율적인 저수준의 텐서 조작 루틴이며, 전형적으로 포트란Fortran이나 C 언어로 구현되어 있습니다.

넘파이는 다음과 같은 원소별 연산을 엄청난 속도로 처리합니다

import numpy as np

z = x + y               # 원소별 덧셈

z = np.maximum(z, 0.)   # 원소별 렐루 함수

 

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)입니다. 이제 XY의 크기가 같으므로 더할 수 있습니다.

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

def naive_add_matrix_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)입니다.

 

2.3.3 텐서 점곱

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

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

import numpy as np
z = np.dot(x, y)

z = x · y

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

def naive_vector_dot(x, y):
    assert len(x.shape) == 1  # x와 y는 넘파이 벡터입니다.
    assert len(y.shape) == 1
    assert x.shape[0] == y.shape[0]

    z = 0.
    for i in range(x.shape[0]):
        z += x[i] * y[i]
    return z

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

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

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) 가 같지 않습니다.22

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

def naive_matrix_dot(x, y):
    assert len(x.shape) == 2   # x와 y는 넘파이 행렬입니다.
    assert len(y.shape) == 2
    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와 같이 입력과 출력을 배치해 보면 어떤 크기의 점곱이 가능한지 이해하는 데 도움이 됩니다.

075

그림 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)

 

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.5 텐서 연산의 기하학적 해석

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

A = [0.5, 1]

이 포인트는 2D 공간에 있습니다(그림 26 참고). 일반적으로 그림 27과 같이 원점에서 포인트를 연결하는 화살표로 벡터를 나타냅니다.

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

078

그림 2-8 두 벡터의 덧셈에 대한 기하학적 해석

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

 

2.3.6 딥러닝의 기하학적 해석

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

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

079

그림 2-9 복잡한 데이터의 매니폴드(manifold)24 펼치기

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

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

 


 

17 역주 Dense 클래스의 객체가 모델(예를 들어 앞서 보았던 Sequential 클래스)의 add() 메서드에 추가될 때 Dense 객체의 build() 메서드가 호출되면서 가중치(커널) W와 편향 b가 생성됩니다. 각각 Dense 객체의 kernel과 bias 인스턴스 변수에 저장됩니다.
18 역주 렐루(ReLU) 함수는 입력이 0보다 크면 입력을 그대로 반환하고 0보다 작으면 0을 반환합니다.
19 역주 파이썬의 함수 매개변수는 수정 가능한mutable 데이터 타입(리스트, 딕셔너리 등)일 경우 참조에 의한 호출call by reference처럼 작동하기 때문에 입력 배열 원본을 변경시키지 않으려면 예제 코드처럼 복사하여 사용해야 합니다.
20 역주 대표적인 BLAS 구현으로는 OpenBLAS, 인텔 MKL, ATLAS 등이 있습니다. 아나콘다 파이썬 배포판은 기본적으로 MKL 라이브러 리를 사용합니다.
21 역주 텐서플로에서는 tf.matmul(x, y)처럼 사용합니다. 파이썬 3.5 이상에서는 x @ y처럼 계산할 수도 있습니다. 케라스에서는 from keras import backend as K; K.dot(x, y)처럼 사용합니다.
22 역주 벡터-벡터 점곱은 교환 법칙이 성립하지만 행렬-벡터, 행렬-행렬 점곱은 교환 법칙이 성립하지 않습니다.
23 역주 아핀 변환은 점, 직선, 평면을 보존하는 아핀 공간으로의 변환입니다. 이 변환은 거리의 비율과 직선의 평행을 유지하는 이동, 스케일링, 회전 등이 포함됩니다.
24 역주 매니폴드는 국부적으로는 저차원의 유클리디안 거리로 볼 수 있는 고차원 공간을 말합니다. 이 그림에서 뭉쳐진 종이 공이 2차원 매니폴드의 한 예입니다.

 

2.3 신경망의 톱니바퀴: 텐서 연산목차 | 2.4 신경망의 엔진: 그래디언트 기반 최적화

 

이 글은 도서출판 길벗에서 출간한  “케라스 창시자에게 배우는 딥러닝“의 1장~3장입니다. 이 책의 저작권은 (주)도서출판 길벗에 있으므로 무단 복제 및 무단 전제를 금합니다.

답글 남기기

아래 항목을 채우거나 오른쪽 아이콘 중 하나를 클릭하여 로그 인 하세요:

WordPress.com 로고

WordPress.com의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Twitter 사진

Twitter의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

Facebook 사진

Facebook의 계정을 사용하여 댓글을 남깁니다. 로그아웃 /  변경 )

%s에 연결하는 중

This site uses Akismet to reduce spam. Learn how your comment data is processed.