TF의 텐서와 상수, 변수, 플레이스홀더

텐서플로우TensorFlow의 기본 데이터 구조인 텐서Tensor는 보통 다차원 배열이라고 말합니다. 텐서플로우에는 세 가지의 핵심 데이터 구조인 상수Constant, 변수Variable, 플레이스홀더Placeholder가 있습니다. 텐서와 이 세 가지 타입은 어떤 관계가 있는 것일까요? “텐서플로 첫걸음“에서는 이들에 대한 자세한 설명이 없이 바로 선형회귀 모델을 간단히 만드는 것으로 시작하고 있습니다. 이 글에서 책에서 부족했던 텐서와 상수, 변수, 플레이스홀더에 대해 살펴 보겠습니다.

텐서플로우의 텐서를 다차원 배열로 많이 설명하지만 이는 맞기도 하고 틀리기도 합니다. 이로 인해 다소 오해도 발생합니다. C++ API에서 말하는 텐서는 다차원 배열에 가깝습니다. 메모리를 할당하고 데이터 구조를 직접 챙깁니다. 하지만 파이썬 API 입장에서 텐서는 메모리를 할당하거나 어떤 값을 가지고 있지 않으며 계산 그래프의 연산(Operation) 노드(Node)를 가리키는 객체에 가깝습니다. 우리가 주로 다루는 파이썬 API의 텐서는 넘파이(NumPy)의 다차원 배열 보다는 어떤 함수를 의미하는 수학 분야의 텐서에 더 비슷합니다.

상수 텐서 하나를 만들어 보겠습니다(이 글에 있는 코드는 주피터 노트북에서 작성된 것으로 명시적으로 print 문을 사용하지 않고 있습니다. 이 노트북은 깃허브에서 볼 수 있습니다). 기본 그래프에 상수나 변수를 계속 추가하면 들여다 보기가 어려우므로 각기 그래프를 따로 만들겠습니다.

g1 = tf.Graph()
with g1.as_default():
    c1 = tf.constant(1, name="c1")

c1 은 “c1” 이란 이름을 가지고 정수값 1을 가진 상수입니다. c1​ 의 타입을 확인해 보겠습니다.

type(c1)
tensorflow.python.framework.ops.Tensor

c1 이 tensorflow.python.framework.ops 밑에 있는 Tensor 클래스의 객체임을 알 수 있습니다. c1 을 출력해 보겠습니다.

c1
<tf.Tensor 'c1:0' shape=() dtype=int32>

텐서플로우가 그래프를 만들고 실행하는 두 단계 구조를 가지고 있으므로 c1 을 출력하면 상수값 1이 출력되는 것이 아니고 이 상수가 텐서라는 것과 이름, 크기(여기서는 스칼라), 데이터 타입이 출력됩니다. 앞서 파이썬의 텐서는 연산 노드를 가리킨다고 했는데 c1 텐서는 어떨까요?

c1.op
<tf.Operation 'c1' type=Const>

텐서의 op 속성에는 그 텐서에 해당하는 연산이 할당되어 있습니다. 연산의 타입은 Const 이고 tf.Operation 클래스의 객체입니다. 즉 c1 은 Const 타입의 연산으로 이 텐서의 출력을 만듭니다. c1.op 의 노드 정의가 어떻게 되어 있는지 출력해 보겠습니다.

c1.op.node_def
name: "c1"
op: "Const"
attr {
  key: "dtype"
  value {
    type: DT_INT32
  }
}
attr {
  key: "value"
  value {
    tensor {
      dtype: DT_INT32
      tensor_shape {
      }
      int_val: 1
    }
  }
}

c1.op 의 이름은 우리가 설정한대로 “c1″이고 op 은 이전에 보았던 대로 “Const”입니다. 그런데 속성(attr)으로 int_val: 1 인 tensor 를 가지고 있습니다. 이 텐서는 tf.constant 로 만들었을 때 지정한 상수값 1을 가지고 있는 텐서입니다. c1tf.Tensor 타입인데 그 안에 tensor 값이 또 있다는 것이 이상합니다. 즉 c1 텐서와 c1.op 의 노드 속성에 있는 텐서가 다른 위치에서 같은 이름을 사용하고 있다는 것을 알 수 있습니다. 저는 c1 이 연산 Const 노드를 가리키는 어떤 것, 예를 들면 포인터나 함수 등에 더 가깝다고 생각합니다. 텐서플로우에서 두 경우 이름을 달리해서 붙여 놓았으면 어땠을까요. 어쨋든 지금은 c1 을 텐서라고 불러야 합니다. 이를 그림으로 나타내 보았습니다.

+_001

전체 그래프 정의를 보면 c1 노드 하나만 있는 것을 확인할 수 있습니다.

g1.as_graph_def()
node {
  name: "c1"
  op: "Const"
  attr {
    key: "dtype"
    value {
      type: DT_INT32
    }
  }
  attr {
    key: "value"
    value {
      tensor {
        dtype: DT_INT32
        tensor_shape {
        }
        int_val: 1
      }
    }
  }
}
versions {
  producer: 21
}

c1 을 다차원 배열이 아닌 연산 노드를 가리키는 무엇으로 생각한다면 아래처럼 세션으로 실행한다는 맥락이 자연스럽습니다. c1Session.run 에 넣으면 상수를 만드는 연산이 실행될 것입니다.

with tf.Session(graph=g1) as sess:
    print(sess.run(c1))
1

c1 텐서의 값 1이 출력되었습니다!

다시 c1 을 확인해 보겠습니다. c1 은 여전히 이전 텐서 타입 그대로이며 run() 메소드 실행 이후에도 변경되지 않았습니다. 더군다나 c1 을 이용해서 실제 값을 출력할 수도 없습니다. c1 은 연산 노드를 가진 실행의 대상을 가리킬 뿐 계산된 값을 캐싱하고 있지 않기 때문입니다.

c1
<tf.Tensor 'c1:0' shape=() dtype=int32>

이번에는 변수를 하나 만들어 보겠습니다.

g2 = tf.Graph()
with g2.as_default():
    v1 = tf.Variable(initial_value=1, name="v1")

텐서플로우의 Variable 클래스는 Tensor 와 마찬가지로 최상위 객체입니다. v1 의 타입을 확인해 보겠습니다.

type(v1)
tensorflow.python.ops.variables.Variable

Variable 클래스의 위치가 Tensor 클래스와는 조금 다릅니다. v1 을 출력해 보겠습니다.

v1
<tf.Variable 'v1:0' shape=() dtype=int32_ref>

v1을 출력해 보니 tf.Variable 로 되어 있고 데이터 타입이 int23_ref 로 되어 있습니다. v1 의 연산 노드 정의를 살펴 보겠습니다.

v1.op.node_def
name: "v1"
op: "VariableV2"
attr {
  key: "container"
  value {
    s: ""
  }
}
attr {
  key: "dtype"
  value {
    type: DT_INT32
  }
}
attr {
  key: "shape"
  value {
    shape {
    }
  }
}
attr {
  key: "shared_name"
  value {
    s: ""
  }
}

v1 에 할당된 연산은 VariableV2 입니다. 그런데 사실 v1op 을 가지고 있는 것은 아닙니다. Variable 클래스는 _variable 텐서를 속성으로 가지고 있습니다.

v1._variable
<tf.Tensor 'v1:0' shape=() dtype=int32_ref>

이 노드의 정의를 보면 이전에 만든 상수 텐서 c1 과 유사하지만 데이터 타입이 int32 가 아니고 int32_ref 인 텐서라는 것을 알수 있습니다.

_variable 텐서의 노드 정의는 v1 의 노드 정의와 동일한데 사실 v1 의 노드 정의는 _variable 텐서의 노드 정의를 리턴한 것입니다. 다시 말하면 실제 그래프 노드 정의는 Variable 클래스 객체의 _variable 텐서에 있습니다.

v1._variable.op.node_def
name: "v1"
op: "VariableV2"
attr {
  key: "container"
  value {
    s: ""
  }
}
attr {
  key: "dtype"
  value {
    type: DT_INT32
  }
}
attr {
  key: "shape"
  value {
    shape {
    }
  }
}
attr {
  key: "shared_name"
  value {
    s: ""
  }
}

그럼 변수를 만들 때 지정한 초기값 1은 어디로 간 것일까요? 전체 그래프 정의를 확인해 보겠습니다(출력이 길어 상세 부분은 표시하지 않았습니다. 깃허브에서 전체 내용을 확인해 보세요).

g2.as_graph_def()
node {
  name: "v1/initial_value"
  op: "Const"
  attr {
    key: "dtype"
    value {
      type: DT_INT32
    }
  }
  attr {
    key: "value"
    value {
      tensor {
        dtype: DT_INT32
        tensor_shape {
        }
        int_val: 1
      }
    }
  }
}
node {
  name: "v1"
  op: "VariableV2"
  ...
}
node {
  name: "v1/Assign"
  op: "Assign"
  input: "v1"
  input: "v1/initial_value"
  ...
}
node {
  name: "v1/read"
  op: "Identity"
  input: "v1"
  ...
}
versions {
  producer: 21
}

변수 하나를 만들었는데 그래프의 노드는 4개가 생성되었습니다. “v1” 노드는 위에서 v1.op.node_def 로 확인한 것과 동일합니다. “v1/Assign” 노드는 “Assign” 연산이고 입력값으로 “v1” 과 “v1/inital_value” 노드를 가리킵니다. “v1/inital_value” 노드는 “Const” 연산이고(위에서 우리가 만든 c1 의 연산과 같네요) init_val: 1 인 텐서를 가지고 있습니다. “v1/Assign” 노드를 실행하면 “v1” 에 “v1/inital_value” 노드의 값을 할당한다고 짐작할 수 있습니다. 마지막으로 “v1/read” 노드는 “Identity” 연산으로 “v1” 의 값을 출력합니다. 와우 복잡하네요. 변수 하나를 만들면(“v1”) 메모리 어디엔가에 저장된 변수의 값(“v1/inital_value”)을 노드에 할당하고(“v1/Assign”), 변수의 값을 읽기 위한 노드(“v1/read”)를 각각 만들어야 합니다. 아래 그림을 참고하세요.

+_000

변수는 세션에서 실행시키기 전에 모두 초기값이 할당되어야 합니다. 현재 세션의 모든 변수를 초기화 시키는 global_variable_initializer 함수가 어떤 역할을 하는지 살펴 보겠습니다.

with tf.Session(graph=g2) as sess:
    init = tf.global_variables_initializer()
init
<tf.Operation 'init' type=NoOp>

global_variable_initializer 함수에서 리턴받은 init 값은 가짜 연산이라는 뜻의 NoOp 로 되어있지만 tf.Operation 의 객체입니다. 그러므로 init 의 노드 정의를 살펴 볼 수 있습니다.

init.node_def
name: "init"
op: "NoOp"
input: "^v1/Assign"

global_variable_initializer 함수는 v1 변수의 할당 노드인 “v1/Assign”을 참조하고 있습니다. 변수를 하나 더 추가하고 global_variable_initializer 함수의 리턴값이 어떻게 변하는지 다시 확인해 보겠습니다.

with g2.as_default():
    v2 = tf.Variable(initial_value=2, name="v2")

with tf.Session(graph=g2) as sess:
    init = tf.global_variables_initializer()
init.node_def
name: "init_2"
op: "NoOp"
input: "^v1/Assign"
input: "^v2/Assign"

예상대로 두 개 변수의 할당 연산 노드를 모아 주고 있습니다. global_variable_initializer 함수는 여러 변수의 초기화 노드를 한번에 실행하는 데 편리합니다.  그럼 각 변수의 initializer 메소드는 할당 연산 노드를 가리키는 것일까요?

v1.initializer
<tf.Operation 'v1/Assign' type=Assign>

맞습니다. v1.initializer 는 “v1/Assign” 노드를 나타냅니다. 따라서 아래처럼 변수마다 각기 할당 연산을 실행하거나 global_variable_initializer 를 사용해서 한번에 초기화하는 것은 동일합니다. 변수를 초기화한 후에는 변수의 값을 출력해볼 수 있습니다. 변수를 세션으로 실행한다는 것은 변수안에 있는 _variable 텐서를 실행하는 것과 동일합니다.

with tf.Session(graph=g2) as sess:
    sess.run(v1.initializer)
    sess.run(v2.initializer)
    # 위 두 라인과 동일한 효과를 냅니다.
    sess.run(tf.global_variables_initializer())
    # 변수를 실행한다는 것은 변수안의 텐서 연산을 실행하는 것입니다.
    print(sess.run([v1, v2]))
    print(sess.run([v1._variable, v2._variable]))
[1, 2]
[1, 2]

파이썬 API의 텐서와 변수에 대해 이해가 잘 되셨나요? 이런 구조는 일반 프로그래밍에서 데이터 타입을 다루는 것과는 스타일이 많이 다릅니다. 그래서 아래와 같은 코드에서 다차원 배열이라고 생각한 텐서 v 가 왜 값이 계속 증가하지 않는지 이상해할 수 있습니다. v 는 연산 노드를 가리키는 텐서라고 생각하면 납득이 됩니다.

g3 = tf.Graph()
with g3.as_default():
    v = tf.Variable(initial_value=1, name="v3")
    v = v + 1

with tf.Session(graph=g3) as sess:
    sess.run(tf.global_variables_initializer())
    print(sess.run(v))
    print(sess.run(v))
2
2

v+1 이 한 세션에서 두 번 실행됐으므로 3이 되어야 할 것 같지만 그렇지 못했습니다. v 를 출력해 보죠.

v
<tf.Tensor 'add:0' shape=() dtype=int32>

v 가 tf.Variable 이 아니고 tf.Tensor 가 되었습니다. 즉 v + 1 을 계산하기 위한 연산 노드로 바뀐 것입니다. v 텐서의 연산 노드 정의를 살펴 보겠습니다.

v.op._node_def
name: "add"
op: "Add"
input: "v3/read"
input: "add/y"
attr {
  key: "T"
  value {
    type: DT_INT32
  }
}

연산 타입이 “Add” 이고 두 개의 입력 “v3/read” 와 “add/y” 를 사용합니다. 이번에는 전체 그래프 정의를 살펴 보겠습니다.

g3.as_graph_def()
node {
  name: "v3/initial_value"
  op: "Const"
  ...
}
node {
  name: "v3"
  op: "VariableV2"
  ...
}
node {
  name: "v3/Assign"
  op: "Assign"
  input: "v3"
  input: "v3/initial_value"
  ...
}
node {
  name: "v3/read"
  op: "Identity"
  input: "v"
  ...
}
node {
  name: "add/y"
  op: "Const"
  attr {
    key: "dtype"
    value {
      type: DT_INT32
    }
  }
  attr {
    key: "value"
    value {
      tensor {
        dtype: DT_INT32
        tensor_shape {
        }
        int_val: 1
      }
    }
  }
}
node {
  name: "add"
  op: "Add"
  input: "v3/read"
  input: "add/y"
  ...
}
node {
  name: "init"
  op: "NoOp"
  input: "^v3/Assign"
}
versions {
  producer: 21
}

우리가 만든 “VariableV2” 연산 노드의 이름은 “v3” 그대로 입니다. 이전 변수 예제의 그래프 정의처럼 “v3/Assign”, “v3/read”, “v3/initial_value” 노드가 있고 덧셈 v + 1 에서 1을 위한 상수 “add/y” 노드가 추가되었습니다. global_variable_initializer 함수를 사용했으므로 “NoOp” 노드도 추가되어 있습니다. 그리고 v 가 가리키고 있는 “Add” 타입의 노드가 있습니다. 세션에서 v 를 계속 실행해도 값이 증가되지 않는 이유는 “v3/read” 는 계속 1로 고정되어 있기 때문입니다. 그림으로 노드의 연결을 표시하면 훨씬 이해하기 쉽습니다.

73900d344d524e72a2b7bbfbf659a26c

그래서 tf.assign 함수가 필요하게 됩니다.

g4 = tf.Graph()
with g4.as_default():
    v = tf.Variable(initial_value=1, name="v3")
    v = tf.assign(v, v+1)

with tf.Session(graph=g4) as sess:
    sess.run(tf.global_variables_initializer())
    print(sess.run(v))
    print(sess.run(v))
2
3

tf.assign 함수를 사용하니 v 값이 증가했습니다. 이전의 v 와 어떻게 달라진 걸까요?

v
<tf.Tensor 'Assign:0' shape=() dtype=int32_ref>

v 역시 텐서이지만 데이터 타입이 int32_ref 로 달라졌습니다. 이 타입은 변수의 _variable 텐서에서 보았던 타입과 동일합니다. v 의 노드 정의를 살펴 보겠습니다.

v.op.node_def
name: "Assign"
op: "Assign"
input: "v3"
input: "add"
...

v 의 연산은 “Assign” 타입의 노드이고 “v3” 와 “add” 를 입력으로 받습니다. 이 연산은 변수의 초기화 “v3/Assign” 노드와 비슷하게 “add” 노드의 계산값을 “v3″에 할당합니다. 결국 v 는 우리가 만든 변수가 아니라 “Assign” 텐서 노드가 되었습니다. 그럼 변수 “v3” 은 어떻게 되었을까요? 전체 그래프 정의를 살펴보겠습니다.

g4.as_graph_def()
node {
  name: "v3/initial_value"
  op: "Const"
  ...
}
node {
  name: "v3"
  op: "VariableV2"
  ...
}
node {
  name: "v3/Assign"
  op: "Assign"
  input: "v3"
  input: "v3/initial_value"
  ...
}
node {
  name: "v3/read"
  op: "Identity"
  input: "v3"
  ...
}
node {
  name: "add/y"
  op: "Const"
  ...
}
node {
  name: "add"
  op: "Add"
  input: "v3/read"
  input: "add/y"
  ...
}
node {
  name: "Assign"
  op: "Assign"
  input: "v3"
  input: "add"
  ...
}
node {
  name: "init"
  op: "NoOp"
  input: "^v3/Assign"
}
versions {
  producer: 21
}

이전 g3 그래프와 거의 같지만 “Add” 노드외에 “Assign” 노드가 하나 더 추가 되었습니다. g3 그래프에서는 v 가 “Add” 연산을 가리켰지만 g4 그래프에서는 v 가 “Assign” 연산을 나타내기 때문에 변수 값을 누적하여 저장할 수 있게 되었습니다. 아래 그림에서 추가된 “Assign” 연산을 확인해 보세요.

ef822b4f449641a7b2f7b53930c125ea

상수와 변수를 보았으니 마지막으로 플레이스홀더를 살펴 보겠습니다. 이번에도 새로운 그래프에서 플레이스홀더를 하나 만듭니다.

g5 = tf.Graph()
with g5.as_default():
    p = tf.placeholder("int32", [1], name="p")
p
<tf.Tensor 'p:0' shape=(1,) dtype=int32>

플레이스홀더 p 도 int32 형인 텐서입니다. tf.Tensor 이므로 이전과 마찬가지로 연산 노드를 가지고 있습니다.

p.op
<tf.Operation 'p' type=Placeholder>

p 의 연산 타입은 Placeholder 입니다. 노드의 그래프 정의를 출력해 보겠습니다.

p.op._node_def
name: "p"
op: "Placeholder"
attr {
  key: "dtype"
  value {
    type: DT_INT32
  }
}
attr {
  key: "shape"
  value {
    shape {
      dim {
        size: 1
      }
    }
  }
}

일반 텐서와 크게 다르지 않아 보입니다. 플레이스홀더는 Placeholder 연산 노드를 가리키는 텐서이며 텐서플로우에서 그래프를 실행할 때 사용자가 데이터를 주입할 수 있는 통로입니다.

with tf.Session(graph=g5) as sess:
    print(sess.run(p, feed_dict={p: [100]}))
100

feed_dict 매개변수를 사용해 세션을 실행할 때 p 에 주입할 값을 지정합니다. 플레이스홀더 텐서 p 의 실행 결과는 주입한 값 그대로를 리턴합니다.

전체 그래프 정의를 출력해 보겠습니다.

g5.as_graph_def()
node {
  name: "p"
  op: "Placeholder"
  attr {
    key: "dtype"
    value {
      type: DT_INT32
    }
  }
  attr {
    key: "shape"
    value {
      shape {
        dim {
          size: 1
        }
      }
    }
  }
}
versions {
  producer: 21
}

플레이스홀더는 변수와는 달리 추가적인 노드를 생성하지 않습니다.

상수와 플레이스홀더는 모두 하나의 연산을 나타내는 텐서입니다. 변수는 참조형 텐서 _variable 을 포함하고 있으며 이 텐서의 초기화와 참조를 위한 추가적인 노드를 그래프에 생성합니다. global_variable_initializer 함수는 변수들의 초기화 연산인 “Assign” 노드를 모두 모은 연산 노드를 추가해 주는 유틸리티 함수 입니다. tf.assign 과 같은 텐서플로우의 연산은 그래프에 연산 노드를 추가하는 것이며 리턴값은 추가된 노드를 가리키고 있는 텐서(tf.Tensor)가 됩니다.

TF의 텐서와 상수, 변수, 플레이스홀더”에 대한 20개의 생각

  1. 박희수

    좋은 글 잘 보고 갑니다. 제가 tensorflow를 막 시작하는 입장이라 아직 이해가 부족해서요;;
    Add node 가 참조하는 assign 노드를 추가하는 예제에서 왜 v3/Assign 노드를 사용하지 않고 새로 노드를 추가하는 것인가요? 기존에 있던 node를 재활용하지 않는 이유가 있나요??

    좋아요

    응답
    1. 박해선 글의 글쓴이

      v3/Assign 노드는 v3 변수에 초기값을 넣어주는 초기화 연산입니다. 이 할당 연산을 변경시킨다면 제대로 변수 초기화되지 않습니다. 🙂

      좋아요

      응답
  2. 닐반

    핸즈온 머신러닝을 보다가 궁금한게 생겨 질문남깁니다. 300p 8번 주석을 보면 ‘Op객체는 run() 메서드, 텐서객체는 eval() 메서드’를 사용한다고 나오는데요… 두 객체를 차이를 잘 모르겠습니다. 튜토리얼이나 가이드를 읽어봐도 혼란스럽기만 합니다. 코드를 봤을 때 ‘이것은 Op이고, 저것은 텐서이다’ 라고 할 수 있는 기준이 있나요?

    좋아요

    응답
    1. 박해선 글의 글쓴이

      파이썬은 정적타입 언어가 아니다 보니 헷갈릴 때가 많은게 사실입니다. 제일 좋은 방법은 type(variable_name)으로 변수의 타입을 출력해 보세요. Op은 tensorflow.python.framework.ops.Operation, 텐서는 tensorflow.python.framework.ops.Tensor이 리턴됩니다. 🙂

      좋아요

      응답
  3. ALiCE

    a = tf.constant(1) # type(a): tensor
    b = tf.constant(2) # type(b): tensor
    c = a + b # tensor(c): tensor

    TensorBoard에서는 이 tensor들이 모두 Node로 나오는데 이건 TensorBoard가 엄밀하게 계산 그래프를 그리는 게 아니라 legend 대로 constant 등의 tensor도 계산 그래프의 연산처럼 Node로 그리기 때문이겠죠??

    좋아요

    응답
  4. minwoo

    안녕하세요, 포스트 잘 보고 있습니다. 현재 Variable에 관련된 노드들을 보고 있습니다.
    먼저 상수 c1 노드는 값을 보기(읽기) 위해 Const와 연결(바라보고) 있다고 생각했습니다.

    그러나 변수 선언에서 v1이라는 노드가 2개 등장하게 되는데 이는 v1 그 자체(노란 사각형 즉, Variable)와 v1._variable(원형 노드 즉, Tensor)라고 생각하면 될까요?

    **v1의 노드 정의는 _variable 텐서의 노드 정의를 리턴한다고 하셨는데 잘 이해가 안됩니다..

    네모 노드가 무엇인지 원형 노드가 무엇인지 한번만 설명해주실 수 있나요?

    좋아요

    응답
    1. 박해선 글의 글쓴이

      안녕하세요. 네모는 계산 그래프의 노드가 아니라 파이썬 객체를 의미합니다. 변수의 노드 정의를 출력하면 _variable 속성(텐서)의 노드 정의가 출력됩니다.

      좋아요

      응답

댓글 남기기

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