머신 러닝 교과서/ 텐서플로의 구조 자세히 알아보기

텐서플로의 주요 특징

  • 텐서플로는 머신 러닝 알고리즘을 구현하고 실행하기 위한 프로그래밍 인터페이스이며, 확장이 용이하고 다양한 플랫폼을 지원한다.
  • 텐서플로의 주요 특징 중 하나는 여러 개의 GPU를 사용할 수 있는 기능이다. 덕분에 대규모 시스템에서 머신 러닝 모델을 매우 효율적으로 훈련할 수 있다.
  • 텐서플로는 모바일 환경을 지원하기 때문에 모바일 애플리케이션 개발에도 적합하다.

텐서플로의 랭크와 텐서

  • 텐서플로 라이브러리를 사용하여 텐서에 대한 연산과 함수를 계산 그래프로 정의할 수 있다.
    • 텐서는 데이터를 담고 있는 다차원 배열에 대한 일반화된 수학적 용어로 텐서 차원을 일반적으로 랭크(rank)라고 한다.
  • 지금까지는 대부분 랭크 0에서 랭크 2사이의 텐서를 다루었다. 텐서는 더 고차원으로 일반화 될 수 있는데, 여러 개의 컬러 채널을 가진 이미지를 다루기 위해 랭크 3인 입력과 랭크 4인 가중치 텐서를 사용한다.
  • 텐서의 개념을 직관적으로 이해하기 위해 아래 그림을 참고 하자.

텐서의 랭크와 크기를 확인하는 방법

  • tf.rank 함수를 사용하여 텐서 랭크를 확인할 수 있다. tf.rank는 출력으로 텐서를 반환한다.
  • 텐서 랭크 외에도 텐서플로 텐서의 크기를 얻을 수도 있다(넘파이 배열의 크기와 비슷하다)
    • 예컨대 X가 텐서이면 X.get_shape()를 사용하여 크기를 구한다. 이 메서드는 TensorShape이라는 특별한 클래스의 객체를 반환한다.
    • tf.rank 함수와 get_shape 메서드를 사용하는 것은 아래 코드를 참고하라
import tensorflow as tf
import numpy as np

t1 = tf.constant(np.pi)
t2 = tf.constant([1, 2, 3, 4])
t3 = tf.constant([[1, 2], [3, 4]])

r1 = tf.rank(t1)
r2 = tf.rank(t2)
r3 = tf.rank(t3)

s1 = t1.get_shape()
s2 = t2.get_shape()
s3 = t3.get_shape()

print('크기:', s1, s2, s3)
print('랭크:', r1.numpy(), r2.numpy(), r3.numpy())

### 결과
# 크기: () (4,) (2, 2)
# 랭크: 0 1 2
  • t1 텐서는 단순한 스칼라이므로 랭크가 0이다. 크기가 ()
  • t2 벡터는 네 개의 원소를 가지므로 랭크가 1이다. 크기는 원소 하나로 이루어진 튜플 (4, )
  • 2×2 행렬인 t3의 랭크는 2이고 크기는 (2, 2) 튜플이다.

텐서를 다차원 배열로 변환

  • 텐서 변환에 사용되는 여러 연산을 살펴보겠다. 일부 연산들은 넘파이 배열 연산과 매우 비슷하게 작동한다.
    • 랭크 2 이상인 텐서를 다룰 때 전치(tanspose) 같은 변환은 주의를 기울여야 한다.
  • 넘파이는 arr.shape 속성으로 배열 크기를 얻을 수 있다.
import tensorflow as tf
import numpy as np

arr = np.array([[1., 2., 3., 3.5], [4., 5., 6., 6.5], [7., 8., 9., 9.5]])
T1 = tf.constant(arr)
print(T1)

s = T1.get_shape()
print('T1의 크기:', s)
print('T1의 크기:', T1.shape)

T2 = tf.Variable(np.random.normal(size=s))
print(T2)

T3 = tf.Variable(np.random.normal(size=s[0]))
print(T3)

### 결과
# tf.Tensor(
# [[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]], shape=(3, 4), dtype=float64)
# T1의 크기: (3, 4)
# T1의 크기: (3, 4)
# <tf.Variable 'Variable:0' shape=(3, 4) dtype=float64, numpy=
# array([[ 1.24591289, 0.42771636, -0.23259698, -0.31242843],
# [-0.51809936, -0.89199958, -0.49494362, 0.55808144],
# [-0.11522273, -0.24953113, -0.20146965, 1.40888624]])>
# <tf.Variable 'Variable:0' shape=(3,) dtype=float64, numpy=array([-0.56337014, 0.80102639, 0.84823994])>
  • T2를 만들기 위해 s를 사용했고 T3를 만들 때는 s의 인덱스를 참조했다.
    • get_shape() 메서드는 TensorShape 객체를 반환한다.
  • 텐서플로 1.x에서는 차원 크기를 얻기 위해 TensorShape 객체의 인덱스를 참조하면 Dimension 클래스의 객체가 반환된다.
    • 따라서 다른 텐서를 만들 때 바로 사용할 수 없었고 s.as_list()처럼 TensorShape 객체를 파이썬 리스트로 변환한 후 인덱스를 참조해야 했었다.
    • 텐서플로 2.x에서는 편리하게 TensorShape 객체의 인덱스를 참조하면 스칼라 값이 반환된다.
  • 이제 텐서 크기를 바꾸는 방법을 알아보자. 넘파이에서는 np.reshape이나 arr.reshape을 사용하는데, 텐서플로에서는 tf.reshape 함수를 사용하여 텐서 크기를 바꾼다.
    • 넘파이에서처럼 차원 하나를 -1로 지정할 수 있다. 이 차원 크기는 다른 차원에 지정한 크기를 바탕으로 결정된다.
    • 다음 코드에서 텐서 T1을 랭크 3인 T4와 T5로 변환했다.
T4 = tf.reshape(T1, shape=[1, 1, -1])
print(T4)

T5 = tf.reshape(T1, shape=[1, 3, -1])
print(T5)

### 결과
# tf.Tensor([[[1. 2. 3. 3.5 4. 5. 6. 6.5 7. 8. 9. 9.5]]], shape=(1, 1, 12), dtype=float64)
# tf.Tensor(
# [[[1. 2. 3. 3.5]
# [4. 5. 6. 6.5]
# [7. 8. 9. 9.5]]], shape=(1, 3, 4), dtype=float64)
  • 넘파이에는 배열을 전치할 수 있는 방법이 세 가지가 있다. arr.T, arr.transpose(), np.transpose(arr)이다. 텐서플로에서는 tf.transpose 함수를 사용한다.
    • 일반적인 전치 연산 외에 perm=[…]에 원하는 순서대로 차원을 지정하여 바꿀 수 있다.
T6 = tf.transpose(T5, perm=[2, 1, 0])
print(T6)

T7 = tf.transpose(T5, perm=[0, 2, 1])
print(T7)

### 결과
# tf.Tensor(
# [[[1. ]
# [4. ]
# [7. ]]

# [[2. ]
# [5. ]
# [8. ]]

# [[3. ]
# [6. ]
# [9. ]]

# [[3.5]
# [6.5]
# [9.5]]], shape=(4, 3, 1), dtype=float64)

# tf.Tensor(
# [[[1. 4. 7. ]
# [2. 5. 8. ]
# [3. 6. 9. ]
# [3.5 6.5 9.5]]], shape=(1, 4, 3), dtype=float64)
  • tf.split 함수를 사용하여 다음과 같이 텐서를 더 작은 텐서의 리스트로 나눌 수도 있다.
t5_split = tf.split(T5, num_or_size_splits=2, axis=2)
print(t5_split)

### 결과
# [<tf.Tensor: id=30, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[1., 2.],
# [4., 5.],
# [7., 8.]]])>, <tf.Tensor: id=31, shape=(1, 3, 2), dtype=float64, numpy=
# array([[[3. , 3.5],
# [6. , 6.5],
# [9. , 9.5]]])>]
  • 출력 결과는 더 이상 하나의 텐서가 아니라 텐서의 리스트이다.
  • 마지막으로 또 다른 유용한 변환은 텐서 연결이다. 크기와 dtype이 같은 텐서 리스트가 있다면 tf.concat 함수로 연결하여 하나의 큰 텐서를 만들 수 있다.
t1 = tf.ones(shape=(5, 1), dtype=tf.float32)
t2 = tf.zeros(shape=(5, 1), dtype=tf.float32)
print(t1)
print(t2)

t3 = tf.concat([t1, t2], axis=0)
print(t3)

t4 = tf.concat([t1, t2], axis=1)
print(t4)

### 결과
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(5, 1), dtype=float32)
# tf.Tensor(
# [[1.]
# [1.]
# [1.]
# [1.]
# [1.]
# [0.]
# [0.]
# [0.]
# [0.]
# [0.]], shape=(10, 1), dtype=float32)
# tf.Tensor(
# [[1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]
# [1. 0.]], shape=(5, 2), dtype=float32)

텐서플로의 계산 그래프 이해

  • 앞선 장에서 설명한 것처럼 텐서플로 2.x 버전에서는 즉시 실행 모드가 기본으로 활성화 되어 있기 때문에 계산 그래프를 만들지 않고 빠르게 개발과 테스트를 할 수 있다.
    • 모델을 다른 프레임워크와 공유하거나 실행 성능을 높이기 위해 계산 그래프를 만들려면 어떻게 해야 할까?
  • 간단한 예를 통해 텐서플로 2.x 버전에서 계산 그래프를 만든느 방법을 알아보자.
    • 랭크 0텐서 (스칼라) a, b, c 가 있을 때, z = 2 \times (a - b) + c 를 평가한다고 가정하자.
    • 이 계산은 아래 그림과 같은 계산 그래프로 표현할 수 있다.

  • 여기서 계산 그래프는 단순히 노드들의 네트워크이다. 각 노드는 한 개 이상의 입력 텐서를 받고 0개 이상의 출력 텐서를 반환하는 연산으로 표현할 수 있다.
  • 텐서플로 2.x에서는 계산 그래프를 만들지 않고 바로 텐서 z를 계산할 수 있다. a, b, c 는 스칼라이다. 텐서플로 상수로 이들을 정의하겠다.
import tensorflow as tf
import numpy as np

a = tf.constant(1)
b = tf.constant(2)
c = tf.constant(3)

z = 2 * (a - b) + c

print('2 * (a - b) + c => ', z.numpy())

### 결과
# 2 * (a - b) + c => 1
  • 이전 장에서 언급했듯이 텐서플로 1.x 버전은 계산 그래프를 만든 후 세션을 통해 그래프를 실행한다. 텐서플로 1.x 버전에서 계산 그래프를 만들고 실행하는 각 단계를 자세히 정리하면 다음과 같다.
    1. 비어 있는 새로운 계산 그래프를 만든다.
    2. 계산 그래프에 노드(텐서와 연산)을 추가한다.
    3. 그래프를 실행한다.
      1. 새로운 세션을 싲가한다.
      2. 그래프에 있는 변수를 초기화한다.
      3. 이 세션에서 계산 그래프를 실행한다.
  • 위 그림에서처럼 z = 2 \times (a - b) + c 를 평가하는 그래프를 만든다면 tf.graph()를 호출하여 그래프를 만들고 다음과 같이 노드를 추가한다.
## 텐서플로 1.x 방식
g = tf.Graph()

## 그래프에 노드를 추가한다
with g.as_default():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
    c = tf.constant(3, name='c')
    z = 2 * (a - b) + c

## 그래프를 실행한다
with tf.compat.v1.Session(graph=g) as sess:
    print('2 * (a - b) + c => ', sess.run(z))
  • 이 코드에서 with g.as_default()를 사용하여 그래프 g에 노드를 추가했다.
    • 텐서플로 1.x에서는 명시적으로 그래프를 지정하지 않으면 항상 기본 그래프가 설정된다.
    • 그 다음 tf.Session을 호출하여 세션 객체를 만들고 tf.Session(graph=g)처럼 실행할 그래프를 매개변수로 전달한다.
    • 텐서플로 세션에서 그래프를 적재한 후에는 이 그래프에 있는 노드를 실행시킬 수 있다.
    • 여기서 텐서와 연산을 텐서플로의 계산 그래프 안에 정의했다는 것을 기억해야 한다. 텐서플로 세션은 그래프에 있는 연산을 실행한 후 결과를 평가하고 추출하기 위해 사용된다.
  • 그래프 g에 들어 있는 연산을 출력해 보자. tf.Graph() 객체의 get_operation() 메서드를 사용하면 된다.
print(g.get_operations())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>]
  • 이번에는 그래프 g의 정의를 출력해 보자. as_graph_deg() 메서드를 호출하면 포맷팅된 문자열로 그래프 정의를 출력한다.
print(g.as_graph_def())

### 결과
# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • 여기까지가 텐서플로 1.x 버전의 계산 그래프였다. 텐서플로 2.x 버전에서는 tf.function 데코레이터를 사용하여 일반 파이썬 함수를 호출 가능한 그래프 객체로 만든다. 마치 tf.Graph와 tf.Session을 합쳐 놓은 것처럼 생각할 수 있다.
    • 위 코드를 tf.function 데코레이터를 사용하여 다시 작성해 보겠다.
@tf.function
def simple_func():
   a = tf.constant(1)
    b = tf.constant(2)
   c = tf.constant(3)
   z = 2 * (a - b) + c
    return z

print('2 * (a - b) + c => ', simple_func().numpy())
  • simple_func() 함수에서 반환되는 텐서를 바로 numpy() 메서드로 변환하여 출력했다. simple_func() 함수는 보통 파이썬 함수처럼 호출할 수 있지만 데코레이터에 의해 객체가 바뀌었다.
print(simple_func.__class__)

### 결과
# <class 'tensorflow.python.eager.def_function.Function'>
  • 파이썬의 다른 데코레이터처럼 다음과 같이 스면 simple_func 객체를 좀 더 이해하기 쉽다.
import tensorflow as tf
import numpy as np

def simple_func():
   a = tf.constant(1, name='a')
    b = tf.constant(2, name='b')
   c = tf.constant(3, name='c')
    z = 2 * (a - b) + c
    return z

simple_func = tf.function(simple_func)
print('2 * (a - b) + c => ', simple_func().numpy())
  • tf.function으로 감싼 함수 안의 연산은 자동으로 텐서플로 그래프에 포함되어 실행된다. 이를 자동 그래프(AutoGraph) 기능이라고 한다.
  • simple_func가 만든 그래프에 있는 연산과 그래프 정의를 확인해 보자
    • 그래프의 정의를 얻으려면 1.x와 마찬가지로 Graph 객체의 as_graph_def() 메서드를 호출하면 된다.
con_func = simple_func.get_concrete_function()
print(con_func.graph.get_operations())
print(con_func.graph.as_graph_def())

### 결과
# [<tf.Operation 'a' type=Const>, <tf.Operation 'b' type=Const>, <tf.Operation 'c' type=Const>, <tf.Operation 'sub' type=Sub>, <tf.Operation 'mul/x' type=Const>, <tf.Operation 'mul' type=Mul>, <tf.Operation 'add' type=AddV2>, <tf.Operation 'Identity' type=Identity>]

# node {
# name: "a"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 1
# }
# }
# }
# }
# node {
# name: "b"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "c"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 3
# }
# }
# }
# }
# node {
# name: "sub"
# op: "Sub"
# input: "a"
# input: "b"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "mul/x"
# op: "Const"
# attr {
# key: "dtype"
# value {
# type: DT_INT32
# }
# }
# attr {
# key: "value"
# value {
# tensor {
# dtype: DT_INT32
# tensor_shape {
# }
# int_val: 2
# }
# }
# }
# }
# node {
# name: "mul"
# op: "Mul"
# input: "mul/x"
# input: "sub"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "add"
# op: "AddV2"
# input: "mul"
# input: "c"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# node {
# name: "Identity"
# op: "Identity"
# input: "add"
# attr {
# key: "T"
# value {
# type: DT_INT32
# }
# }
# }
# versions {
# producer: 119
# }
  • Identity 노드가 추가된 것 외에는 전체 구조가 동일하다.

텐서플로의 변수

  • 텐서플로에서 변수는 특별한 종류의 텐서 객체이다. 훈련 기간 동안 모델 파라미터를 저장하고 업데이트할 수 있다.
    • 예컨대 신경망의 입력층, 은닉층, 출력층에 있는 가중치이다.
    • 변수를 정의할 때 초기 텐서값을 지정해야 한다.
  • 텐서플로 변수를 정의하는 방법은 다음과 같다.
tf.Variable(<initial-value>, name=<optional-name>)
  • tf.Variable에는 shape이나 dtype을 설정할 수 없다. 크기와 타입은 초깃값과 동일하게 설정된다.
  • 텐서플로 1.x에서 변수를 만들고 공유하기 위해 사용했던 tf.get_variable() 함수는 2.x 버전에서는 삭제되었다.
    • 하위 버전과 호환성을 위해 tensorflow.compat.v1 아래로 이동되었다.
    • 텐서플로 1.x의 실제 변수 값은 파이썬 프로그램 객체가 아니라 그래프 노드로 존재한다. 파이썬 객체는 변수가 평가된 결과를 얻기 위한 참조 역할을 할 뿐이다.
    • 따라서 그래프에서 변수 노드를 공유하기 위해 변수 범위(variable scope)를 복잡하게 다루어야 했다.
    • 텐서플로 2.x에서는 보통 파이썬 객체를 공유하듯 변수를 공유할 수 있다.
  • 다음 코드에서 두 버전의 차이를 살펴보겠다. 우선 텐서플로 1.x 형식의 변수이다. 그래프 컨텍스트 블록 안에서 변수를 그래프에 추가했다.
import tensorflow as tf
import numpy as np

g1 = tf.Graph()

with g1.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')

print(w1)

### 결과
# <tf.Variable 'w1:0' shape=(2, 4) dtype=int32>
  • 변수 w1은 실제 값이 포함되어 있지 않다. g1 그래프에 포함된 연산을 출력해 보면 변수 초깃값(initial_value)과 할당 연산(Assign)이 각각 존재한다.
print(g1.get_operations())

### 결과
# [<tf.Operation 'w1/Initializer/initial_value' type=Const>, <tf.Operation 'w1' type=VarHandleOp>, <tf.Operation 'w1/IsInitialized/VarIsInitializedOp' type=VarIsInitializedOp>, <tf.Operation 'w1/Assign' type=AssignVariableOp>, <tf.Operation 'w1/Read/ReadVariableOp' type=ReadVariableOp>]
  • 이 변수는 사용하기 전에 초기화를 해주어야 한다. 변수 하나씩 초기화 연산을 실행해도 되지만 변수가 많을 때는 번거롭다.
    • 앞서 보았던 global_variables_initializer() 함수의 역할이 여기에 있다. 이 함수는 그래프에 있는 변수 초기화 연산을 한꺼번에 모아 준다.
with g1.as_default():
   init = tf.compat.v1.global_variables_initializer()
    print(init.node_def)

### 결과
# name: "init"
# op: "NoOp"
# input: "^w1/Assign"
  • 텐서플로 1.x 버전에서 변수를 다룰 때는 초기화 외에 주의해야 할 점이 하나 더 있다.
    • 다음과 같이 일반적인 파이썬 프로그래밍으로 코드를 작성하면 w1은 텐서플로 변수가 아니라 덧셈 연산을 가리키는 텐서로 바뀐다.
    • w1을 여러번 실행하더라도 변수 값이 증가하지 않고 원래 초깃값에 1을 더한 결과만 반복된다.
with g1.as_default():
    w1 = w1 + 1
    print(w1)

### 결과
# Tensor("add:0", shape=(2, 4), dtype=int32)


with tf.compat.v1.Session(graph=g1) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[2 3 4 5]
# [6 7 8 9]]
  • 이런 결과는 w1이 그래프에 있는 노드를 참조하는 역할이라고 생각하면 이해하기 쉽다.
    • 이를 해결하려면 텐서플로 1.x에서는 assign() 메서드를 사용하여 변수 값을 증가시키는 연산을 만들어야 한다.
    • 그 다음 변수를 초기화하고 변수 값을 증기사키는 연산을 실행해야 한다.
g2 = tf.Graph()

with g2.as_default():
    w1 = tf.Variable(np.array([[1,2,3,4], [5,6,7,8]]), name='w1')
    w1 = w1.assign(w1+1)

with tf.compat.v1.Session(graph=g2) as sess:
   init = tf.compat.v1.global_variables_initializer()
    sess.run(init)
   print(sess.run(w1))
    print(sess.run(w1))

### 결과
# [[2 3 4 5]
# [6 7 8 9]]
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • 텐서플로 2.x에서는 텐서플로 변수가 파이썬 객체 자체이므로 훨씬 다루기 쉽다. 동일한 초깃값을 가진 변수 w2를 정의해보자.
w2 = tf.Variable(np.array([[1, 2, 3, 4], [5, 6, 7, 8]]), name='w2')
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[1, 2, 3, 4],
# [5, 6, 7, 8]])>
  • 텐서플로 1.x와 달리 2.x에서는 w2+1처럼 계산이 덧셈 연산의 출력 텐서가 아니라 덧셈이 적용된 상수 텐서를 반환한다.
print(w2 + 1)

### 결과
# tf.Tensor(
# [[2 3 4 5]
# [6 7 8 9]], shape=(2, 4), dtype=int32)
  • 따라서 텐서플로 2.x에서는 변수 값을 증가시키려면 이 덧셈 결과를 assign() 메서드로 반복해서 전달하면 된다. 호출 결과는 변수에 즉각 반영된다.
w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[2 3 4 5]
# [6 7 8 9]]

w2.assign(w2 + 1)
print(w2.numpy())

### 결과
# [[ 3 4 5 6]
# [ 7 8 9 10]]
  • w2 변수를 다시 출력해 보면 numpy 속성이 바뀐 것을 볼 수 있다.
print(w2)

### 결과
# <tf.Variable 'w2:0' shape=(2, 4) dtype=int32, numpy=
# array([[ 3, 4, 5, 6],
# [ 7, 8, 9, 10]])>

tf.keras API 자세히 배우기

Sequential 모델

  • Sequential 모델은 층을 순서대로 쌓은 모델을 만든다. 13장에서 완전 연결 층인 Dense 층을 쌓은 모델을 만들어 보았는데, 15, 16장에서 Sequential 모델에 합성곱 층과 순환층을 적용해 보겠다.
  • 여기서는 간단한 회귀 분석을 위한 모델을 만든다. \hat{y} = wx + b 인 선형 회귀 모델을 구현하는 것이 목표이다.
    • 먼저 make_random_data 함수를 이용하여 하나의 특성을 가진 랜덤한 회귀용 데이터를 만들고 시각화 해 보자
import numpy as np
import matplotlib.pyplot as plt

np.random.seed(0)

def make_random_data():
   x = np.random.uniform(low=-2, high=2, size=200)
    y = []

   for t in x:
        r = np.random.normal(loc=0.0, scale=(0.5 + t*t/3), size=None)
       y.append(r)   

    return x, 1.726 * x - 0.84 + np.array(y)

x, y = make_random_data()

plt.plot(x, y, 'o')
plt.show()

  • 샘플 개수는 200개로 이중 150개를 훈련세트, 50개를 테스트 세트로 나누어 놓겠다.
    • 모델을 튜닝하기 위해서는 검증 세트가 필요한데, 친절하게도 tf.keras는 모델을 훈련할 때 간단한 설정만으로도 검증 세트의 점수를 자동으로 계산해 주기 때문에 훈련 세트와 테스트 세트로만 나눈다.
x_train, y_train = x[:150], y[:150]
x_test, y_test = x[150:], y[150:]
  • 예제 데이터가 준비되었으므로 모델을 만들어보겠다. 1차원 데이터셋이므로 출력 유닛 하나를 가진 간단한 완전 연결 층 하나를 추가하겠다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.summary()

### 결과
# Model: "sequential"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 유닛이 하나뿐이므로 모델 파라미터 개수는 가중치와 절편 두 개 뿐이다. 이 모델을 컴파일하고 훈련 세트에서 학습시켜보자.
model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

### 결과
# Train on 105 samples, validate on 45 samples
# Epoch 1/500
# 2020-05-02 10:03:27.367560: I tensorflow/stream_executor/platform/default/dso_loader.cc:44] Successfully opened dynamic library cublas64_100.dll
# 105/105 [==============================] - 1s 8ms/sample - loss: 5.2795 - val_loss: 3.1418
# Epoch 2/500
# 105/105 [==============================] - 0s 133us/sample - loss: 4.3258 - val_loss: 2.7091
# ...
# Epoch 500/500
# 105/105 [==============================] - 0s 114us/sample - loss: 0.7574 - val_loss: 0.9054
  • compile() 메서드의 loss 매개변수에 회귀 문제를 위한 평균 제곱 오차(mean squared error) 손실 함수 ‘mse’를 선택했다.
    • 회귀 문제 이외에도 평균 절댓값 오차(mean absolute error) 손실 함수인 ‘mae’를 지정할 수도 있다.
    • 13장에서 다중 클래스 분류 문제를 위한 범주형 크로스 엔트로피 ‘categorical_crossentropy’를 사용해 보았다. 이진 분류 문제에는 이진 크로스 엔트로피 ‘binary_crossentropy’를 선택한다.
  • optimizer 매개변수에는 경사 하강법 옵티마이저를 지정한다.
    • 13장에서는 SGD 클래스의 객체를 직접 만들어 전달했다. SGD 클래스의 기본값을 사용할 때 간편하게 ‘sgd’로 지정할 수 있다.
    • SGD 클래스의 기본값은 학습률 lr이 0.001이고 momentum과 decay는 0이다.
    • 이외에도 인기 있는 여러 종류의 옵티마이저를 선택할 수 있는데, RMSprop 알고리즘을 위한 ‘rmsprop’, Adagrad는 ‘adagrad’, Adam은 ‘adam’을 선택한다.
  • fit() 메서드에서 검증 세트의 크기를 30%로 설정했다.
    • 훈련 과정을 출력해주는 verbose 매개변수의 기본값은 1로 진행바와 함께 검증 점수를 출력해준다.
    • verbose 매개변수를 0으로 지정하면 아무것도 출력되지 않는다.
    • fit() 메서드에서 반환받은 History 객체의 history 딕셔너리에는 에포크마다 계산한 손실 함수 값이 저장되어 있다. 훈련 세트에 대한 손실 함수 값은 ‘loss’에 담겨 있다.
    • 이 값을 그래프로 그리면 다음과 같다.
epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training losss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

함수형 API

  • Sequential 모델은 층을 순서대로 쌓은 네트워크를 만든다. 이따금 이보다 더 복잡한 모델을 만들어야 할 때가 있다. tf.keras의 함수형 API는 다양한 토폴로지(topology)의 네트워크를 만들 수 있다.
    • 함수형 API에서는 입력과 출력 사이에 원한느 층을 자유롭게 조합할 수 있다.
    • 예제를 만들면서 살펴보자
import tensorflow as tf
from tensorflow.keras import Model, Input

input = tf.keras.Input(shape=(1,))
output = tf.keras.layers.Dense(1)(input)
  • 먼저 튜플로 입력 크기를 지정하여 Input 클래스의 객체를 만든다. 그 다음 앞서 보았던 Dense 클래스를 마치 함수처럼 호출한다. 이는 클래스 객체를 함수처럼 호출할 수 있는 파이썬의 특수한 __call__() 메서드를 사용한 것이다.
    • 마지막 코드는 다음과 같이 쓸 수 있다.
# 이렇게 쓰거나
dense = tf.keras.layers.Dense(1)
output = dense(input)

# 이렇게 쓸 수 있다
dense = tf.keras.layers.Dense(1)
output = dense.__call__(input)
  • Dense 층의 객체(dense)를 네트워크의 다른 부분에 재사용하지 않는다면(즉, 이 층의 가중치를 공유하지 않는다면) 객체 생성과 동시에 호출하는 것이 편리하다.
  • 그 다음 Model 클래스에 입력과 출력을 전달하여 신경망 모델을 만든다.
model = tf.keras.Model(input, output)
model.summary()

### 결과
# Model: "model"
# _________________________________________________________________
# Layer (type) Output Shape Param #
# =================================================================
# input_1 (InputLayer) [(None, 1)] 0
# _________________________________________________________________
# dense (Dense) (None, 1) 2
# =================================================================
# Total params: 2
# Trainable params: 2
# Non-trainable params: 0
  • 이전과 동일하게 두 개의 모델 파라미터를 가진 Dense 층 하나로 이루어져 있다.
    • Sequential 모델에서는 없었던 InputLayer는 입력 데이터를 위한 층이다. 이 층은 학습되는 파라미터를 가지고 있지 않다.
    • 사실 Sequential 모델은 summary() 메서드 출력에서 나타나지는 않지만 자동으로 InputLayer를 추가해 준다. Sequential 모델의 model._layers 속성을 출력하여 모델에 들어 있는 층을 모두 확인해 보면 좋다.
  • 함수형 API로 모델을 만든 후 컴파일과 훈련하는 단계는 Sequential 모델과 완전히 동일하다. 이전과 동일한 매개변수로 선형 회귀 모델을 훈련시키고 그래프로 그려 보자
    • 이번에는 훈련 손실과 함께 검증 세트에 대한 손실도 같이 그래프로 출력해 보겠다. 검증 세트에 대한 손실은 ‘val_loss’ 로 확인할 수 있다.
model.compile(optimizer='sgd', loss='mse')
history = model.fit(x_train, y_train, epochs=500, validation_split=0.3)

epochs = np.arange(1, 500+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

tf.keras 모델의 저장과 복원

  • tf.keras API로 만든 모델을 저장하고 복원하는 것은 아주 쉽다. 우선 앞서 만든 모델 가중치를 save_weights() 메서드로 저장해 보자.
model.save_weights('simple_weights.h5')
  • 위 코드를 실행하면 현재 폴더에 ‘simple_weights.h5’ 파일을 생성하고 모든 층의 가중치를 저장한다.
    • save_weights 메서드는 기본적으로 텐서플로의 체크포인트(checkpoint) 포맷으로 가중치를 저장한다.
    • save_format 매개변수를 ‘h5’로 지정하여 HDF5 파일 포맷으로 저장할 수 있다. 이 메서드는 똑똑하게도 파일 이름의 확장자가 .h5이면 자동으로 HDF5 포맷으로 저장한다.
    • 저장된 가중치를 사용하려면 새로운 모델을 만들고 load_weights() 메서드를 사용하여 가중치를 로드할 수 있다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

model.load_weights('simple_weights.h5')
  • 이 코드는 모델을 훈련시키지 않고 이전에 학습한 가중치를 사용한다. 가중치가 올바르게 로드되었는지 확인하기 위해 테스트 세트에 대한 손실 점수를 계산해 보자. 테스트 세트를 평가하려면 evaluate() 메서드를 사용한다.
model.evaluate(x_test, y_test)
  • 가중치 외에 모델 전체를 저장하기 위해 tf.keras 모델은 save() 메서드를 사용하여 가중치와 네트워크 구조까지 HDF5 포맷으로 저장할 수 있다. 모델을 저장하는 코드는 다음과 같다.
model.save('simple_model.h5')
  • 저장된 모델을 다시 로드하려면 load_model() 함수를 사용하면 된다. 저장된 모델 구조를 그대로 사용하기 때문에 다시 층을 추가할 필요가 없다.
model = tf.keras.models.load_model('simple_model.h5')
  • 모델을 훈련하는 동안 ModelCheckpoint 콜백을 사용하여 최고의 성능을 내는 가중치를 저장할 수 있다.
    • ModelCheckpoint 콜백은 더 이상 성능이 개선되지 않을 때 훈련을 멈추게 하는 EarlyStopping 콜백과 함께 사용하는 경우가 많다.
  • EarlyStopping 콜백 클래스는 기본적으로 검증 손실을 모니터링 한다. patience 매개변수가 지정한 에포크 횟수 동안 모니터링 지표가 개선되지 않으면 훈련을 중지한다.
  • 다음 코드는 ModelCheckpoint 콜백을 사용하는 예이다. 검증 손실을 모니터링하면서 최상의 모델 가중치를 저장한다.
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1))
model.compile(optimizer='sgd', loss='mse')

callback_list = [tf.keras.callbacks.ModelCheckpoint(filepath='my_model.h5', monitor='val_loss', save_best_only=True), tf.keras.callbacks.EarlyStopping(patience=5)]

history = model.fit(x_train, y_train, epochs=500, validation_split=0.2, callbacks=callback_list)
  • 손실 그래프를 그려 보면 검증 손실이 감소되지 않으므로 훈련이 일찍 멈춘 것을 확인할 수 있다.
epochs = np.arange(1, len(history.history['loss'])+1)

plt.plot(epochs, history.history['loss'], label='Training loss')
plt.plot(epochs, history.history['val_loss'], label='Validation loss')
plt.xlabel('Epochs')
plt.ylabel('Loss')
plt.legend()
plt.show()

  • 저장된 모델을 로드한 후 ModelCheckpoint 콜백에서 저장한 가중치를 load_weights() 메서드로 적재한다. 이렇게 하면 모델 구조와 함께 최상의 모델 가중치를 유지할 수 있다.
model = tf.keras.models.load_model('simple_model.h5')
model.load_weights('my_model.h5')
model.evaluate(x_test, y_test)
  • 여기까지 학습한 선형 모델을 그래프로 그려보자. 짙은 원은 훈련 세트이고, 밝은 원은 테스트 세트이다.
x_arr = np.arange(-2, 2, 0.1)
y_arr = model.predict(x_arr)

plt.figure()
plt.plot(x_train, y_train, 'bo')
plt.plot(x_test, y_test, 'bo', alpha=0.3)
plt.plot(x_arr, y_arr, '-r', lw=3)
plt.show()

계산 그래프 시각화

  • 텐서플로의 놀라운 기능 중 하나는 텐서보드(TensorBoard)이다. 텐서보드는 모델의 학습 과정 뿐만 아니라 계산 그래프도 시각화할 수 있는 모듈이다.
    • 계산 그래프를 시각화 하면 노드 사이 연결을 보고 의존성을 확인하고 필요할 때 모델을 디버깅 할 수 있다.
  • 케라스 API에서는 콜백 함수를 통해 텐서 보드를 사용한다.
    • (예제는 앞선 절에서 했던 것을 그대로 사용한다)
model = tf.keras.Sequential()
model.add(tf.keras.layers.Dense(units=1, input_dim=1, kernel_regularizer='l2'))

callback_list = [TensorBoard()]

model.compile(optimizer='sgd', loss='mse')

history = model.fit(x_train, y_train, epochs=500, validation_split=0.3, callbacks=callback_list)
  • TensorBoard 콜백은 기본적으로 현재 폴더 아래에 log 폴더를 만들고 그래프와 손실 값을 저장한다. 터미널에서 다음 명령을 실행해 보자.
tensorboard --logdir=logs/

[실행 결과]
TensorBoard 2.0.2 at http://localhost:6006/ (Press CTRL+C to quit)
  • 위 결과와 같이 URL 주소가 출력되는데, 이 주소를 브라우저 주소 표시줄에 붙여 넣으면 아래 그림과 같이 텐서보드에 접속할 수 있다.

  • 그래프 탭을 클릭하면 모델에 상응하는 그래프가 나타난다.

  • 큰 사각 상자는 서브 네트워크를 나타낸다. 서브 네트워크의 모든 컴포넌트는 이 사각 상자 안에 그룹지어 있고, 이 상자를 확장하여 상세 사항을 살펴 볼 수 있다.
    • 마우스로 상자를 더블 클릭하면 상자가 확장된다.
    • 아래 그림과 같이 dense 서브 네트워크의 상세 사항을 볼 수 있다.

  • 그래프를 살펴보면 dense 네트워크는 두 개의 가중치 켄서 kernel과 bias를 가지고 있다. kernel은 입력과 곱한(MatMul) 후에 bias와 더하는 (BiasAdd) 과정이 그래프에 잘 나타나 있다.

텐서보드 익숙하게 다루기

케라스의 층 그래프 그리기

  • 케라스는 층의 그래프를 그릴 수 있는 plot_model() 함수를 제공한다. 이 함수를 사용하려면 pydot 파이썬 패키지를 설치해야 한다.
    • 두 개의 은닉층을 가진 간단한 모델을 만들어 plot_model() 함수를 적용해 보겠다.
input = tf.keras.Input(shape=(784,))
hidden = tf.keras.layers.Dense(100)(input)
output = tf.keras.layers.Dense(10)(hidden)

model = tf.keras.Model(input, output)
  • plot_model() 함수를 호출한다. to_file 매개변수에는 그래프에 저장할 파일 이름을 지정한다.
tf.keras.utils.plot_model(model, to_file='model_1.png')
  • model_1.png 파일을 열어 보면 다음과 같다. 네모 상자 안에 층 이름과 클래스 이름을 보여준다.

  • plot_model 함수의 show_shapes 매개변수를 True로 지정하면 층 입력과 출력 크기를 함께 나타내 준다.

[ssba]

The author

지성을 추구하는 사람/ suyeongpark@abyne.com

댓글 남기기

이 사이트는 스팸을 줄이는 아키스밋을 사용합니다. 댓글이 어떻게 처리되는지 알아보십시오.