Dive into Deep Learning
Dive into Deep Learning

2.3. 데이터 조작(data manipulation)

데이터를 변경할 수 없다면 아무것도 할 수 없습니다. 일반적으로 우리는 데이터를 사용해서 두가지 중요한 일을 합니다. (i) 데이터를 얻고, (ii) 컴퓨터에서 들어오면 처리하기. 데이터를 저장하는 방법을 모른다면 데이터를 얻는 것의 의미가 없으니, 합성된 데이터를 다루는 것부터 시작하겠습니다.

MXNet에서 데이터를 저장하고 변경하는 주요 도구인 NDArray를 소개하겠습니다. NumPy를 사용해봤다면, NDArray가 NumPy의 다차원 배열과 디자인 측면에서 비슷하다는 것을 눈치챌 것입니다. 하지만, 주요 장점들이 있습니다. 첫번째로는 NDArray는 CPU, GPU 그리고 분산 클라우드 아키텍처에서 비동기 연산을 지원합니다. 두번째는, 자동 미분을 지원합니다. 이 특징들 때문에 NDArray는 머신 러닝에 이상적인 요소라고 할 수 있습니다.

2.3.1. 시작하기

이 절에서는 여러분은 기본적인 것을 다룰 것입니다. 요소별 연산이나 표준 분포와 같은 기본적인 수학 내용을 이해하지 못해도 걱정하지 마세요. 다음 두 절에서 필요한 수학과 어떻게 코드로 구현하는지를 다룰 예정입니다. 수학에 대해서 더 알고 싶다면, 부록에 “Math” 를 참고하세요.

MXNet과 MXNet의 ndarray 모듈을 import 합니다. 여기서는 ndarraynd 라고 별칭을 주겠습니다.

[1]:
import mxnet as mx
from mxnet import nd

우리가 만들 수 있는 가장 단순한 객체는 벡터입니다. arange 는 12개의 연속된 정수를 갖는 행 벡터를 생성합니다.

[2]:
x = nd.arange(12)
x
[2]:

[ 0.  1.  2.  3.  4.  5.  6.  7.  8.  9. 10. 11.]
<NDArray 12 @cpu(0)>

x 를 출력할 때 나온 <NDArray 12 @cpu(0)> 로 부터, 우리는 이것이 길이가 12인 일차원 배열이고, CPU의 메인메모리에 저장되어 있다는 것을 알 수 있습니다. @cpu(0)에서 0은 아무런 의미가 없고, 특정 코어를 의미하지도 않습니다.

NDArray 인스턴스의 shape은 shape 속성으로 얻습니다.

[3]:
x.shape
[3]:
(12,)

size 속성은 NDArray 인스턴스의 원소 총 개수를 알려줍니다. 우리는 벡터를 다루고 있기 때문에 두 결과는 같습니다.

[4]:
x.size
[4]:
12

행 벡터를 3행, 4열의 행렬로 바꾸기 위해서, 즉 shape을 바꾸기 위해서 reshape 함수를 사용합니다. 모양(shape)이 바뀌는 것을 제외하고는 x 의 원소와 크기는 변하지 않습니다.

[5]:
x = x.reshape((3, 4))
x
[5]:

[[ 0.  1.  2.  3.]
 [ 4.  5.  6.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

위와 같이 행렬의 모양을 바꾸는 것은 좀 이상할 수 있습니다. 결국, 3개 행을 갖는 행렬을 원한다면 총 원소의 개수가 12개가 되기 위해서 열이 4가 되어야한다는 것을 알아야합니다. 또는, NDArray에게 행의 개수가 몇 개이든지 모든 원소를 포함하는 열이 4개인 행렬을 자동으로 찾아내도록 요청하는 것도 가능합니다. 즉, 위의 경우에는 x.reshape((3, 4))x.reshape((-1, 4))x.reshape((3, -1)) 와 같습니다.

[6]:
nd.empty((3, 4))
[6]:

[[1.0633190e+13 4.5801440e-41 9.1457414e+29 3.0769712e-41]
 [0.0000000e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00]
 [0.0000000e+00 0.0000000e+00 0.0000000e+00 0.0000000e+00]]
<NDArray 3x4 @cpu(0)>

empty 메소드는 모양(shape)에 따른 메모리를 잡아서 원소들의 값을 설정하지 않고 행렬를 반환합니다. 이는 아주 유용하지만, 원소들이 어떤 형태의 값이라도 가질 수 있는 것을 의미합니다. 이는 매우 큰 값들일 수도 있습니다. 하지만, 일반적으로는 행렬을 초기화하는 것을 원합니다.

보통은 모두 0으로 초기화하기를 원합니다. 수학자들은 이차원 보다 큰 객체들에 대해서는 특별한 이름을 쓰지 않지만, 우리는 이것들은 텐서(tensor)라고 부르겠습니다. 모든 원소가 0이고 모양(shape)이 (2,3,4)인 텐서를 하나 만들기 위해서 다음과 같이 합니다.

[7]:
nd.zeros((2, 3, 4))
[7]:

[[[0. 0. 0. 0.]
  [0. 0. 0. 0.]
  [0. 0. 0. 0.]]

 [[0. 0. 0. 0.]
  [0. 0. 0. 0.]
  [0. 0. 0. 0.]]]
<NDArray 2x3x4 @cpu(0)>

NumPy 처럼, 모든 원소가 1인 텐서를 만드는 방법은 다음과 같습니다.

[8]:
nd.ones((2, 3, 4))
[8]:

[[[1. 1. 1. 1.]
  [1. 1. 1. 1.]
  [1. 1. 1. 1.]]

 [[1. 1. 1. 1.]
  [1. 1. 1. 1.]
  [1. 1. 1. 1.]]]
<NDArray 2x3x4 @cpu(0)>

Python 리스트를 이용해서 NDArray의 각 원소 값을 지정하는 것도 가능합니다.

[9]:
y = nd.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
y
[9]:

[[2. 1. 4. 3.]
 [1. 2. 3. 4.]
 [4. 3. 2. 1.]]
<NDArray 3x4 @cpu(0)>

어떤 경우에는, NDArray의 값을 임의로 채우기를 원할 때가 있습니다. 이는 특히 뉴럴 네트워크의 파라미터로 배열을 사용할 때 일반적입니다. 아래 코드는 shape가 (3,4) NDArray를 생성하고, 각 원소는 평균이 0이고 분산이 1인 표준 분포로부터 임의로 추출한 값을 갖습니다.

[10]:
nd.random.normal(0, 1, shape=(3, 4))
[10]:

[[ 2.2122064   0.7740038   1.0434405   1.1839255 ]
 [ 1.8917114  -1.2347414  -1.771029   -0.45138445]
 [ 0.57938355 -1.856082   -1.9768796  -0.20801921]]
<NDArray 3x4 @cpu(0)>

2.3.2. 연산들

우리는 종종 배열에 함수를 적용할 필요가 있습니다. 아주 간단하고, 굉장히 유용한 함수 중에 하나로 요소별(element-wise) 함수가 있습니다. 이 연산은 두 배열의 동일한 위치에 있는 원소들에 대해 스칼라 연산을 수행하는 것입니다. 스칼라를 스칼라로 매핑하는 함수를 사용하면 언제나 요소별(element-wise) 함수를 만들 수 있습니다. 수학 기호로는 이런 함수를 \(f: \mathbb{R} \rightarrow \mathbb{R}\) 로 표현합니다. 같은 모양(shape)의 두 벡터 \(\mathbf{u}\)\(\mathbf{v}\) 와 함수 f가 주어졌을 때, 모든 \(i\) 에 대해서 \(c_i \gets f(u_i, v_i)\) 을 갖는 벡터 \(\mathbf{c} = F(\mathbf{u},\mathbf{v})\) 를 만들 수 있습니다. 즉, 우리는 스칼라 함수를 벡터의 요소별로 적용해서 벡터 함수 \(F: \mathbb{R}^d \rightarrow \mathbb{R}^d\) 를 만들었습니다. MXNet에서는 일반적인 표준 산술 연산자들(+,-,/,*,**)은 모양(shape)이 무엇이든지 상관없이 두 텐서의 모양(shape)이 같을 경우 모두 요소별 연산으로 간주되어 계산됩니다. 즉, 행렬을 포함한 같은 모양(shape)을 갖는 임의의 두 텐서에 대해서 요소별 연산을 수행할 수 있습니다.

[11]:
x = nd.array([1, 2, 4, 8])
y = nd.ones_like(x) * 2
print('x =', x)
print('x + y', x + y)
print('x - y', x - y)
print('x * y', x * y)
print('x / y', x / y)
x =
[1. 2. 4. 8.]
<NDArray 4 @cpu(0)>
x + y
[ 3.  4.  6. 10.]
<NDArray 4 @cpu(0)>
x - y
[-1.  0.  2.  6.]
<NDArray 4 @cpu(0)>
x * y
[ 2.  4.  8. 16.]
<NDArray 4 @cpu(0)>
x / y
[0.5 1.  2.  4. ]
<NDArray 4 @cpu(0)>

제곱과 같은 더 많은 연산들이 요소별 연산으로 적용될 수 있습니다.

[12]:
x.exp()
[12]:

[2.7182817e+00 7.3890562e+00 5.4598148e+01 2.9809580e+03]
<NDArray 4 @cpu(0)>

요소별 연산과 더불어서, dot 함수를 이용한 행렬의 곱처럼 행렬의 연산들도 수행할 수 있습니다. 행렬 xy 의 전치행렬에 대해서 행렬의 곱을 수행해보겠습니다. x 는 행이 3, 열이 4인 행렬이고, y 는 행이 4개 열이 3개를 갖도록 전치시킵니다. 두 행렬을 곱하면 행이 3, 열이 3인 행렬이 됩니다. (이것이 어떤 의미인지 햇갈려도 걱정하지 마세요. linear algebra 절에서 행렬의 연산에 대한 것들을 설명할 예정입니다.)

[13]:
x = nd.arange(12).reshape((3,4))
y = nd.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
nd.dot(x, y.T)
[13]:

[[ 18.  20.  10.]
 [ 58.  60.  50.]
 [ 98. 100.  90.]]
<NDArray 3x3 @cpu(0)>

여러 NDArray들을 합치는 것도 가능합니다. 이를 위해서는 어떤 차원(dimension)을 따라서 합쳐야 하는지를 알려줘야 합니다. 아래 예제는 각각의 차원 0 (즉, 행들)과 차원 1 (열들)을 따라서 두 행렬을 합칩니다.

[14]:
nd.concat(x, y, dim=0)
nd.concat(x, y, dim=1)
[14]:

[[ 0.  1.  2.  3.  2.  1.  4.  3.]
 [ 4.  5.  6.  7.  1.  2.  3.  4.]
 [ 8.  9. 10. 11.  4.  3.  2.  1.]]
<NDArray 3x8 @cpu(0)>

NumPy에서와 같이 논리 문장을 사용해서 이진 NDArray를 만들 수 있습니다. x == y 를 예로 들어보겠습니다. 만약 xy 가 같은 원소가 있다면, 새로운 NDArray는 그 위치에 1을 갖고, 다른 값이면 0을 갖습니다.

[15]:
x == y
[15]:

[[0. 1. 0. 1.]
 [0. 0. 0. 0.]
 [0. 0. 0. 0.]]
<NDArray 3x4 @cpu(0)>

NDArray의 모든 요소를 더하면 하나의 원소를 갖는 NDArray가 됩니다.

[16]:
x.sum()
[16]:

[66.]
<NDArray 1 @cpu(0)>

asscalar 함수를 이용해서 결과를 Python의 스칼라로 바꿀 수 있습니다. 아래 예제는 x\(\ell_2\) 놈을 계산합니다. 이 결과는 하나의 원소를 갖는 NDArray이고, 이를 Python의 스칼라 값으로 바꿉니다.

[17]:
x.norm().asscalar()
[17]:
22.494442

표기를 편하게 하기 위해서 y.exp(), x.sum(), x.norm(), 등을 각각 nd.exp(y), nd.sum(x), nd.norm(x) 처럼 쓸 수도 있습니다.

2.3.3. 브로드케스트 메카니즘

위 절에서 우리는 같은 모양(shape)의 두 NDArray 객체에 대한 연산을 어떻게 수행하는지를 살펴봤습니다. 만약 모양(shape)이 다른 경우에는 NumPy와 같이 브로드케스팅 메카니즘이 적용됩니다: 즉, 두 NDArray가 같은 모양(shape)을 갖도록 원소들이 복사된 후, 요소별로 연산을 수행하게 됩니다.

[18]:
a = nd.arange(3).reshape((3, 1))
b = nd.arange(2).reshape((1, 2))
a, b
[18]:
(
 [[0.]
  [1.]
  [2.]]
 <NDArray 3x1 @cpu(0)>,
 [[0. 1.]]
 <NDArray 1x2 @cpu(0)>)

ab 는 각각 (3x1), (1x2) 행렬이기 때문에, 두 행렬을 더하기에는 모양(shape)이 일치하지 않습니다. NDArray는 이런 상황을 두 행렬의 원소들을 더 큰 행렬 (3x2)로 ‘브로드케스팅’ 해서 해결합니다. 즉, 행렬 a 는 컬럼을 복제하고, 행렬 b 는 열을 복제한 후, 요소별 덧셈을 수행합니다.

[19]:
a + b
[19]:

[[0. 1.]
 [1. 2.]
 [2. 3.]]
<NDArray 3x2 @cpu(0)>

2.3.4. 인덱싱과 슬라이싱(slicing)

다른 Python 배열처럼 NDArray의 원소들도 인덱스를 통해서 지정할 수 있습니다. Python에서처럼 첫번째 원소의 인덱스는 0이고, 범위는 첫번째 원소는 포함하고 마지막은 포함하지 않습니다. 즉, 1:3 은 두번째와 세번째 원소를 선택하는 범위입니다. 행렬에서 행들을 선택하는 예는 다음과 같습니다.

[20]:
x[1:3]
[20]:

[[ 4.  5.  6.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 2x4 @cpu(0)>

값을 읽는 것 말고도, 행렬의 원소 값을 바꾸는 것도 가능합니다.

[21]:
x[1, 2] = 9
x
[21]:

[[ 0.  1.  2.  3.]
 [ 4.  5.  9.  7.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

여러 원소에 같은 값을 할당하고 싶을 경우에는, 그 원소들에 대한 인덱스를 모두 지정해서 값을 할당하는 것으로 간단히 할 수 있습니다. 예를 들어 [0:2, :] 는 첫번째와 두번째 행을 의미합니다. 행렬에 대한 인덱싱을 이야기해왔지만, 벡터나 2개 보다 많은 차원을 갖는 텐서에도 동일하게 적용됩니다.

[22]:
x[0:2, :] = 12
x
[22]:

[[12. 12. 12. 12.]
 [12. 12. 12. 12.]
 [ 8.  9. 10. 11.]]
<NDArray 3x4 @cpu(0)>

2.3.5. 메모리 절약하기

앞의 예제들 모두 연산을 수행할 때마다 새로운 메모리를 할당해서 결과를 저장합니다. 예를 들어, y = x + y 를 수행하면, 원래의 행렬 y 에 대한 참조는 제거되고, 새로 할당된 메모리를 참조하도록 동작합니다. 다음 예제에서는, 객체의 메모리 주소를 반환하는 Python의 id() 함수를 이용해서 이를 확인해보겠습니다. y = x + y 수행 후, id(y) 는 다른 위치를 가리키고 있습니다. 이렇게 되는 이유는 Python은 y + x 연산 결과를 새로운 메모리에 저장하고, y 가 새로운 메모리를 참조하도록 작동하기 때문입니다.

[23]:
before = id(y)
y = y + x
id(y) == before
[23]:
False

이는 두가지 이유로 바람직하지 않을 수 있습니다. 첫번째로는 매번 불필요한 메모리를 할당하는 것을 원하지 않습니다. 머신 러닝에서는 수백 메가 바이트의 파라미터들을 매 초마다 여러 번 업데이트를 수행합니다. 대부분의 경우 우리는 이 업데이트를 같은 메모리(in-place)를 사용해서 수행하기를 원합니다. 두번째는 여러 변수들이 같은 파라미터를 가리키고 있을 수 있습니다. 같은 메모리에 업데이트를 하지 않을 경우, 메모리 누수가 발생하고, 래퍼런스가 유효하지 않은 파라미터를 만드는 문제가 발생할 수 있습니다.

다행히도 MXNet에서 같은 메모리 연산은 간단합니다. 슬라이스 표기법 y[:] = <expression> 을 이용하면 이전에 할당된 배열에 연산의 결과를 저장할 수 있습니다. zeros_like 함수를 사용해서 동일한 모양(shape)을 갖고 원소가 모두 0인 행렬을 하나 복사해서 이것이 어떻게 동작하는지 보겠습니다.

[24]:
z = y.zeros_like()
print('id(z):', id(z))
z[:] = x + y
print('id(z):', id(z))
id(z): 140382214610224
id(z): 140382214610224

멋져 보이지만, x+y 는 결과값을 계산하고 이를 y[:] 에 복사하기 전에 이 값을 저장하는 임시 버퍼를 여전히 할당합니다. 메모리를 더 잘 사용하기 위해서, ndarray 연산(이 경우는 elemwise_add)을 직접 호출해서 임시 버퍼의 사용을 피할 수 있습니다. 모든 ndarray 연산자가 제공하는 out 키워드를 이용하면 됩니다.

[25]:
before = id(z)
nd.elemwise_add(x, y, out=z)
id(z) == before
[25]:
True

x 값이 프로그램에서 더 이상 사용되지 않을 경우, x[:] = x + y 이나 x += y 로 연산으로 인한 메모리 추가 사용을 줄일 수 있습니다.

[26]:
before = id(x)
x += y
id(x) == before
[26]:
True

2.3.6. NDArray와 NumPy간 상호 변환

MXNet NDArray를 NumPy로 변환하는 것은 간단합니다. 변환된 배열은 메모리를 공유하지 않습니다. 이것은 사소하지만 아주 중요합니다. CPU 또는 GPU 하나를 사용해서 연산을 수행할 때, NumPy가 동일한 메모리에서 다른 일을 수행하는 것을 MXNet이 기다리는 것을 원하지 않기 때문입니다. arrayasnumpy 함수를 이용하면 변환을 할 수 있습니다.

[27]:
import numpy as np

a = x.asnumpy()
print(type(a))
b = nd.array(a)
print(type(b))
<class 'numpy.ndarray'>
<class 'mxnet.ndarray.ndarray.NDArray'>

2.3.7. 문제

  1. 이 절의 코드를 실행하세요. 조건문 x == yx < y 이나 x > y 로 바꿔서 결과가 어떻게 되는지 확인하세요.
  2. 다른 모양(shape)의 행렬들에 브로드케스팅이 적용되는 연산을 수행하는 두 NDArray를 바꿔보세요. 예를 들면 3 차원 텐서로 바꿔보세요. 예상한 결과도 같나요?
  3. 행렬 3개 a, b, c 가 있을 경우, c = nd.dot(a, b.T) + c 를 가장 메모리가 효율적인 코드로 바꿔보세요.

2.3.8. Scan the QR Code to Discuss

image0