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

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

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

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

구체적으로 보면 이 함수는 다음과 같습니다. (W는 2D 텐서고, b는 벡터입니다.1)

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

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


Note

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


2.3.1 원소별 연산

relu함수나 덧셈 같은 원소별 연산은 텐서에 있는 각 원소에 독립적으로 적용됨을 의미합니다. 이는 곧 병렬구현이 가능한 연산이라는 의미입니다.

파이썬으로 단순한 원소별 연산, 예로 덧셈을 구현한다면 다음과 같이 for 반복문을 사용할 것입니다. 3

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]
      # x[i, j] = max(x[i, j], 0)  relu 연산도 동일합니다.
  return x

사실 넘파이 배열을 다룰 때 넘파이에서는 내장 함수로 이런 연산들을 고속 처리할 수 있습니다. 넘파이는 시스템에 설치된 BLAS 구현에 복잡한 일들을 위임하며4, 이는 병렬화되고 효율적인 텐서 조작 루틴으로 포트란(Fortran)이나 C언어로 구현되어 빠른 속도의 연산을 지원합니다.

넘파이로 다음과 같은 연산으로 동일하게 구할 수 있습니다.

import numpy as np
z = x + y               # 원소별 덧셈
# z = np.maximum(z, 0.)   원소별 relu 함수

2.3.2 브로드캐스팅

크기가 다른 텐서를 연산하면 오류가 나야하지만 모호하지 않고 실행 가능하다면 작은 텐서가 큰 텐서의 크기에 맞추어 브로드캐스팅됩니다. 브로드캐스팅은 두 단계로 이루어집니다.

  1. 큰 텐서의 ndim에 맞도록 작은 텐서에 (브로드캐스팅 )축이 추가됩니다.
  2. 작은 텐서가 새 축을 따라서 큰 텐서의 크기에 맞도록 반복됩니다.

글만 읽었을때는 메모리의 낭비가 심합니다. 그렇기 때문에 실제로는 연산의 반복, 즉 알고리즘 수준에서만 연산이 진행됩니다.

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) 연산은 가장 널리 사용되고 유용한 텐서 연산입니다. 행렬 곱으로 생각하면 편합니다.

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

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

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

점곱은 임의의 축 개수를 가진 텐서에 일반화 되지만 가장 일반적인 용도는 두 행렬 간의 점곱일 것입니다. 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와 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. 행렬 점곱 다이어그램

고차원 텐서에서는 다음과 같이 연산이 가능합니다.

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

2.3.4 텐서 크기 변환

텐서 크기 변환은 특정 크기에 맞게 열과 행을 재배열하는 것입니다. 당연히 크기 변환 후에도 원래와 원소의 개수는 동일합니다. 예제를 통해 크기 변환을 알아보겠습니다.

>>> 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 = np.zeros((300, 20))  # 모두 0으로 채워진 (300, 20) 크기의 행렬을 만듭니다.
>>> x = np.transpose(x)
>>> print(x.shape)
(20, 300)

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

텐서의 내용은 어떤 기하학적 공간의 좌표 포인트로 해석될 수 있기 때문에 기하학적 해석이 가능합니다. 예를 들어 2D 공간에 있는 포인트 A = (0.5, 1)를 화살표로 나타냅니다.

나타낼 수 없음
그림 2-7. 화살표로 나타낸 2D 공간에 있는 포인트

이어 새로운 포인트 B = (1, 0.25)를 이전 벡터에 더해 보겠습니다.

나타낼 수 없음
그림 2-8. 두 벡터의 덧셈에 대한 기하학적 해석

기하학적으로는 벡터 화살표를 연결하여 A+B를 계산할 수 있습니다. 최종 위치는 두 벡터의 덧셈을 나타내는 벡터가 됩니다.

일반적으로 아핀 변환(affine transformation)6, 회전(rotation), 스케일링(scaling) 등 기본적인 기하학적 연산은 텐서 연산으로 가능합니다.

2.3.6 딥러닝의 기하학적 해석

생략

  1. Dense 클래스의 객체가 모델(예를 들어 앞서 보았던 Sequential 클래스)의 add() 메서드에 추가될 때 Dense 객체의 build() 메서드가 호출되면서 가중치(커널) W와 편향 b가 생성됩니다. 각각 Dense 객체의 kernel과 bias 인스턴스 변수에 저장됩니다. 

  2. 렐루(ReLU) 함수는 입력이 0보다 크면 입력을 그대로 반환하고 0보다 작으면 0을 반환합니다. 

  3. 파이썬의 함수 매개변수는 수정 가능한(mutable) 데이터 타입(리스트, 딕셔너리 등)일 경우 참조에 의한 호출(call by reference)처럼 작동하기 때문에 입력 배열 원본을 변경시키지 않으려면 예제 코드처럼 복사하여 사용해야 합니다. 

  4. 대표적인 BLAS 구현으로는 OpenBLAS, 인텔 MKL, ATLAS 등이 있습니다. 아나콘다 파이썬 배포판은 기본적으로 MKL 라이브러 리를 사용합니다. 

  5. 텐서플로에서는 tf.matmul(x, y)처럼 사용합니다. 파이썬 3.5 이상에서는 x @ y처럼 계산할 수도 있습니다. 케라스에서는 from keras import backend as K; K.dot(x, y)처럼 사용합니다. 

  6. 아핀 변환은 점, 직선, 평면을 보존하는 아핀 공간으로의 변환입니다. 이 변환은 거리의 비율과 직선의 평행을 유지하는 이동, 스케일링, 회전 등이 포함됩니다.  2

댓글남기기