Записная книжка

Компьютерное зрение, машинное обучение, нейронные сети и т.п.

Tensorflow (часть вторая)

Продолжаем разбираться с Tensorflow. Будем смотреть на вспомогательные операции, которые позволяют усложнить граф вычислений таким образом, чтобы поток данных мог идти по разным путям, в зависимости от содержимого этих данных. Но начнем мы с операции, которая позволит нам логировать сам процесс выполнения вычислений в графе.

Операция печати

Иногда не плохо было бы иметь возможность вывести что-то на печать непосредственно из графа вычислений, чтобы не выдёргивать значения тензоров из сессии наружу. Например, так удобнее логировать процесс выполнения или вставлять отладочные сообщения. В TF для добавления в граф операции печати предусмотрена функция tf.print. Можно, например, переписать код оптимизации функции следующим образом:

import tensorflow as tf

global_step = tf.Variable(0, trainable=False)
learning_rate = tf.constant(0.1, dtype = tf.float32)

X = tf.Variable(0.)
Y = X * X - 2. * X + 1.

print_str = tf.strings.format("{}. x = {}, y = {}", (global_step, X, Y))
print_op = tf.print(print_str)

optimizer = tf.train.GradientDescentOptimizer(learning_rate)
train_op = optimizer.minimize(Y, global_step=global_step)
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    step = 0
    while (step < 50):
        _, step = sess.run([train_op, global_step])
        if (step % 10 == 0):
            sess.run(print_op)

Результат будет, практически таким же как и раньше, только с несколько более убогим форматированием:

10. x = 0.892625809, y = 0.0115292072
20. x = 0.988470733, y = 0.000132918358
30. x = 0.998762, y = 1.54972076e-06
40. x = 0.999867082, y = 0
50. x = 0.999985754, y = 0

Контроль зависимостей

Когда мы в рамках текущей сессии пытаемся получить значение какого-то тензора, то TF пробегает по всем рёбрам входящим в вершину графа от которой мы хотим получить тензор, и вычисляет значения в вершинах присоединённых по этим ребрам, а чтобы вычислить их пробегает по рёбрам в них входящим и так рекурсивно пока не дойдет до вершин без входных рёбер, например, констант, переменных или заполнителей (для последних TF пытается отыскать значения в переданном feed_dict). Однако, иногда надо при выполнении операции в одной вершине, выполнить операцию в другой, никак не связанной с первой. Например, в примере из предыдущего параграфа было бы удобно, не вызывать операцию печати самому, а заставить TF это делать при каждом вызове операции оптимизации. В этом нам поможет контроль зависимостей: tf.control_dependencies. Эта функция создаёт контекст внутри которого все вновь созданные операции гарантированно выполнятся позднее тех операций список которых мы передали в функцию в качестве параметра.

Или иначе говоря вызов любой операции, созданной внутри контекста, дёрнет вначале все операции из списка переданного в tf.control_dependencies. Поправим код нашего примера:

import tensorflow as tf

global_step = tf.Variable(0, trainable=False)
learning_rate = tf.constant(0.1, dtype = tf.float32)

X = tf.Variable(0.)
Y = X * X - 2. * X + 1.

print_str = tf.strings.format("{}. x = {}, y = {}", (global_step, X, Y))
print_op = tf.print(print_str)

optimizer = tf.train.GradientDescentOptimizer(learning_rate)
with tf.control_dependencies([print_op]):
    train_op = optimizer.minimize(Y, global_step=global_step)
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    step = 0
    while (step < 50):
        _, step = sess.run([train_op, global_step])
    sess.run(print_op)

Результат будет следующим:

0. x = 0, y = 1
1. x = 0.2, y = 0.64
2. x = 0.36, y = 0.40959996
3. x = 0.488000035, y = 0.26214397
4. x = 0.59040004, y = 0.167772114
5. x = 0.67232, y = 0.107374191
6. x = 0.73785603, y = 0.0687194467
7. x = 0.790284812, y = 0.0439804792
8. x = 0.832227826, y = 0.0281475186
9. x = 0.865782261, y = 0.018014431
10. x = 0.892625809, y = 0.0115292072
...

Стало значительно удобнее, но печатать на каждом шаге не очень хорошо, хотелось бы вернуться к варианту с выводом каждые $N$ шагов. Для этого нам понадобятся условные операции.

Условные операции

В TF можно добавлять в граф вершины, которые выполняют роль условного оператора т.е. в зависимости от того, что пришло на вход данной вершины, внутри будет выполнена та или другая операция. За добавление такой вершины отвечает функция tf.cond.

Допустим мы решили подсчитывать при помощи TF корень квадратный из числа подаваемого на вход. Можно написать простой код вроде:

import tensorflow as tf

X = tf.placeholder(dtype = tf.float32, shape = (), name = "X")
Y = tf.sqrt(X)
with tf.Session() as sess:
    print(sess.run(Y, feed_dict = {X : 4}))
    print(sess.run(Y, feed_dict = {X : 9}))
    print(sess.run(Y, feed_dict = {X : -16}))

который вполне нормально отработает и выдаст:

2.0
3.0
nan

Однако, допустим мы хотим, чтобы для отрицательных чисел, мы получали 0 вместо nan. Чтобы удовлетворить наше желание, воспользуемся tf.cond:

import tensorflow as tf

X = tf.placeholder(dtype = tf.float32, shape = (), name = "X")
Y = tf.cond(tf.greater_equal(X, tf.constant(0.)), 
            lambda: tf.sqrt(X),
            lambda: tf.constant(0.))
with tf.Session() as sess:
    print(sess.run(Y, feed_dict = {X : 4}))
    print(sess.run(Y, feed_dict = {X : 9}))
    print(sess.run(Y, feed_dict = {X : -16}))

Результатом будет:

2.0
3.0
0.0

С одной стороны всё достаточно просто. Три параметра, первый добавляет вершину, которая умеет сравнить X и 0.0 (ноль завернули в константу, хотя можно было и не заворачивать, питон сделал бы это за нас), и две функции, одна вызывается для случая, когда вершина из первого параметра вернет true, вторая если false. Однако, есть и нюансы.

Во-первых, функция для true и функция для false должны возвращать тензоры содержащие значения одинакового типа. Т.е. например, вот такой код:

...
Y = tf.cond(tf.greater_equal(X, tf.constant(0.)), 
            lambda: tf.sqrt(X),
            lambda: tf.constant(0))
...

выдаст ошибку:

ValueError: Outputs of true_fn and false_fn must have the same type: float32, int32

Потому что функция для true вернёт скаляр типа float32, а функция для false вернёт тоже скаляр, но типа int32. Забавно, что при этом ранг и размерности тензоров для true и для false могут быть разные, т.е. вот такое изменение:

...
Y = tf.cond(tf.greater_equal(X, tf.constant(0.)), 
            lambda: tf.sqrt(X),
            lambda: tf.constant([0., 0.]))
...

Отработает вполне нормально.

Есть и более интересные особенности. Рассмотрим, например, вот такой код:

import tensorflow as tf

pred = tf.constant(False)
a = tf.constant(0)

print_true = tf.print("true_fn\n")
print_false = tf.print("false_fn\n")

def true_fn():
    with tf.control_dependencies([print_true]):
        return tf.identity(a)

def false_fn():
    with tf.control_dependencies([print_false]):
        return tf.identity(a)

cond_op = tf.cond(pred, true_fn, false_fn)
with tf.Session() as sess:
    sess.run(cond_op)

Казалось бы интуитивно понятно, что в результате должно быть напечатано false_fn. Но в результате работы данного кода в консоль будет выведено:

false_fn
true_fn

Самое интересное, если мы слегка подправим наш код:

import tensorflow as tf

pred = tf.constant(False)
a = tf.constant(0)

def true_fn():
    with tf.control_dependencies([tf.print("true_fn\n")]):
        return tf.identity(a)

def false_fn():
    with tf.control_dependencies([tf.print("false_fn\n")]):
        return tf.identity(a)

cond_op = tf.cond(pred, true_fn, false_fn)
with tf.Session() as sess:
    sess.run(cond_op)

то получим результат, который изначально ожидали: только false_fn в консоли. Объясняется это следующим образом. Когда мы создаем операцию, вне функций true_fn, false_fn, мы фактически создаём вершину в графе, результат работы этой вершины, должен быть вычислен и передан в tf.cond до проверки условия, т.е. не зависимо от того понадобится ли нам этот результат. Если же мы создаем операцию внутри функций true_fn или false_fn, то она будет вызывана, после проверки условия и только по необходимости. Такие интересные особенности функции tf.cond, при неаккуратном использовании, могут привести в лучшем случае к существенному замедлению работы, а в худшем еще и к неправильным результатам.

Вернёмся к нашему коду оптимизирующему функцию. Теперь мы можем поправить его таким образом, чтобы печатать не на каждом шаге:

import tensorflow as tf

global_step = tf.Variable(0, trainable=False)
learning_rate = tf.constant(0.1, dtype = tf.float32)

X = tf.Variable(0.)
Y = X * X - 2. * X + 1.

print_str = tf.strings.format("{}. x = {}, y = {}", (global_step, X, Y))
print_step = tf.constant(10)
print_op = tf.cond(tf.equal(tf.mod(global_step, print_step), tf.zero()),
                   lambda: tf.print(print_str),
                   lambda: tf.no_op())

optimizer = tf.train.GradientDescentOptimizer(learning_rate)
with tf.control_dependencies([print_op]):
    train_op = optimizer.minimize(Y, global_step=global_step)
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    step = 0
    while (step < 50):
        _, step = sess.run([train_op, global_step])
    sess.run(print_op)

В результате получится:

0. x = 0, y = 1
10. x = 0.892625809, y = 0.0115292072
20. x = 0.988470733, y = 0.000132918358
30. x = 0.998762, y = 1.54972076e-06
40. x = 0.999867082, y = 0
50. x = 0.999985754, y = 0

Операция ветвления

Функция tf.cond добавляет простое ветвление, которое в зависимости от выполнения условия вызывает одну из двух функций. Если условий много и при выполнении каждого надо вызвать свою функцию, то собирать конструкцию рекурсивно из условных операций сложно и неудобно. Дабы упростить жизнь для такой ситуации в TF предусмотрена функция tf.case. В нее передаются либо список пар: (условие, операция), либо словарь в котором ключ - условие, а значение - операция.

Реализуем в качестве примера функцию:

\[sign(x) = \begin{cases} -1, & x < 0 \\ 0, & x = 0 \\ +1, & x > 0 \end{cases}\]

Например, вот такой код:

import tensorflow as tf

X = tf.placeholder(dtype = tf.float32, shape = (), name = "X")
Y = tf.case([(tf.less(X, tf.constant(0.)), lambda: tf.constant(-1.)),
             (tf.equal(X, tf.constant(0.)), lambda: tf.constant(0.)),
             (tf.greater(X, tf.constant(0.)), lambda: tf.constant(1.))])
with tf.Session() as sess:
    print(sess.run(Y, feed_dict = {X : -4.}))
    print(sess.run(Y, feed_dict = {X : 0.}))
    print(sess.run(Y, feed_dict = {X : 5.}))

выдаст:

-1.0
0.0
1.0

Можно использовать словарь, вместо списка:

...
Y = tf.case({tf.less(X, tf.constant(0.)): lambda: tf.constant(-1.),
             tf.equal(X, tf.constant(0.)): lambda: tf.constant(0.),
             tf.greater(X, tf.constant(0.)): lambda: tf.constant(1.)})
...

Результат будет тот же.

Для tf.case действуют аналогичные ограничения, что и для функции tf.cond, т.е. тип значений внутри тензоров выдаваемых при разных условиях должен быть один и тот же.

Вообще говоря, не обязательно, чтобы при любых значениях параметров выполнялось одно и только одно условие. Например, испортим наш код следующим образом:

Y = tf.case([(tf.less_equal(X, tf.constant(0.)), lambda: tf.constant(-1.)),
             (tf.equal(X, tf.constant(0.)), lambda: tf.constant(0.)),
             (tf.greater_equal(X, tf.constant(0.)), lambda: tf.constant(1.))])

теперь при X = 0. все три условия истинны. В таком случае TF запустит первую операцию, для которой выполнилось условие, т.е. результат будет:

-1.0
-1.0
1.0

А вот если дополнительно при вызове tf.case передать параметр exclusive равный True:

...
Y = tf.case([(tf.less_equal(X, tf.constant(0.)), lambda: tf.constant(-1.)),
             (tf.equal(X, tf.constant(0.)), lambda: tf.constant(0.)),
             (tf.greater_equal(X, tf.constant(0.)), lambda: tf.constant(1.))],
            exclusive = True)
...

то TF проверит выполнение всех условий из списка, и, если выполнено больше одного, вернёт ошибку InvalidArgumentError и даже попытается расписать какие условия были выполнены одновременно.

Так же ошибка возникнет, если не выполнено ни одно из условий, т.е. например, вот такой код:

...
Y = tf.case([(tf.less(X, tf.constant(0.)), lambda: tf.constant(-1.)),
             (tf.greater(X, tf.constant(0.)), lambda: tf.constant(1.))])
...

вернёт ошибку при X = 0.. . В этом случае можно предусмотреть вызов функции по умолчанию и добавить параметр default:

...
Y = tf.case([(tf.less(X, tf.constant(0.)), lambda: tf.constant(-1.)),
             (tf.greater(X, tf.constant(0.)), lambda: tf.constant(1.))],
            default = lambda: tf.constant(0.))
...

и мы снова можем вычислять функцию $sign(x)$ в нуле.

Операция, создающая цикл

Следующая операция, которую мы разберем, позволяет создать в графе вычислений подграф (или вершину), внутри которого выполняются одни и те же операции, пока истинно некоторое условие. За создание такой операции отвечает tf.while_loop. Попробуем, например, с помощью этой функции создать граф для вычисления суммы всех чисел от 1 до 10:

import tensorflow as tf

i = tf.constant(1)
N = tf.constant(10)
sum = tf.constant(0)
sum_op = tf.while_loop(lambda i, sum: tf.less_equal(i, N),
                       lambda i, sum: [tf.add(i, 1), tf.add(sum, i)],
                       [i, sum])
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    print(sess.run(sum_op[1]))

В tf.while_loop в качестве аргументов передаются две функции. Первая cond, в нашем примере это:

lambda i, sum: tf.less(i, N)

и вторая body:

lambda i, sum: [tf.add(i, 1), tf.add(sum, i)]

Функции принимают одни и те же аргументы (например, параметр sum в функции cond совершенно бесполезен, мы его никак не используем внутри, но вынуждены предусмотреть его в списке аргументов), аргументы это либо тензор, либо набор тензоров. Функция cond должна вернуть тензор ранга ноль, содержащий значения типа boolean. У функции body выход должен возвращать тензор или набор тензоров, тех же характеристик, что пришли на вход (этот выход на следующем шаге отправится на вход функций cond и body). Фактически операция tf.while_loop разворачивается в графе в последовательный вызов этих двух функций, вызвали cond проверили результат, если истина вызвали body, если ложь вышли из операции.

Важно, что тензоры передаваемые в качестве параметров функции на протяжении всего цикла сохраняют ранг, размерности и тип. А что делать если по ходу работы цикла нам надо менять характеристики тензора? Ранг тензора и тип значений менять нельзя, но можно менять размерность. Изменим условия нашей задачи. Допустим мы хотим получить не сумму чисел от 1 до 10, а вектор содержащий эти числа. Перепишем код следующим образом:

import tensorflow as tf

i = tf.constant(1)
N = tf.constant(10)
series = tf.constant([], dtype = tf.int32)
series = tf.while_loop( lambda i, series:
                            tf.less_equal(i, N),
                        lambda i, series:
                            [tf.add(i, 1), tf.concat([series, [i]], axis = 0)],
                        [i, series])
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    print(sess.run(series[1]))

И при запуске получим ошибку:

ValueError: Input tensor 'Const_2:0' enters the loop with shape (1,), but has shape
(2,) after one iteration. To allow the shape to vary across iterations, use the
`shape_invariants` argument of tf.while_loop to specify a less-specific shape.

В которой TF логично замечает, что на вход пришёл тензор ранга 1 и размерности 1, а в следующую итерацию передан тензор ранга 1 и размерности 2. А так же есть подсказка воспользоваться параметром shape_invariants функции tf.while_loop. Действительно, мы можем указать характеристики тензоров, которые передаются с шага на шаг цикла. При этом, предусмотрена возможность не фиксировать одну или несколько размерностей тензора. У нас в качестве параметров цикла используется пара тензоров, первый (счётчик цикла i) это тензор нулевого ранга. Второй, в который мы планируем собрать последовательность чисел, это тензор ранга 1, с размерностью меняющейся от итерации к итерации. В TF есть специальный тип объектов отвечающий за shape (профиль, т.е. ранг плюс размерности), используем его:

...
i_shape = tf.TensorShape([])
series_shape = tf.TensorShape([None])
...

Для series_shape мы создаём переменную размерность, поэтому вместо числового значения передаём None. Перепишем код, используя при создании цикла параметр shape_invariants:

import tensorflow as tf

i = tf.constant(1)
N = tf.constant(10)
series = tf.constant([], dtype = tf.int32)
var_shape = [tf.TensorShape([]), tf.TensorShape([None])]
series = tf.while_loop( lambda i, series: 
                            tf.less(i, N),
                        lambda i, series:
                            [tf.add(i, 1), tf.concat([series, [i]], axis = 0)],
                        [i, series],
                        shape_invariants = var_shape)
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    print(sess.run(series[1]))

Результатом работы данного кода будет:

[1 2 3 4 5 6 7 8 9]

Вернемся к нашему коду оптимизирующему функцию $y = x^2 - 2x +1$. Внесём цикл while внутрь графа:

import tensorflow as tf

global_step = tf.Variable(0, trainable=False)

learning_rate = tf.constant(0.1, dtype = tf.float32)
optimizer = tf.train.GradientDescentOptimizer(learning_rate)

dumb_value = tf.constant(0)
def cond(dumb_value):
    return tf.less_equal(global_step, 50)

def body(dumb_value):
    X = tf.Variable(0.)
    Y = X * X - 2. * X + 1.

    print_str = tf.strings.format("{}. x = {}, y = {}", (global_step, X, Y))
    print_step = tf.constant(10)
    print_op = tf.cond(tf.equal(tf.mod(global_step, print_step), tf.constant(0)),
                        lambda: tf.print(print_str),
                        lambda: tf.no_op())
    with tf.control_dependencies([print_op]):
        train_op = optimizer.minimize(Y, global_step=global_step)

    return tf.tuple([dumb_value], control_inputs = [train_op])

while_train_op = tf.while_loop(cond, body, [dumb_value])
with tf.Session() as sess:
    sess.run(tf.global_variables_initializer())
    sess.run(while_train_op)

Что надо отметить. Первое, создание всех операций, включая операцию оптимизации, внесено во внутрь функции body, если это не сделать, то TF решит, что поскольку операция создана вне цикла, до его создания, то вызывать её надо тоже вне цикла один раз. Второе, в TF невозможно создать цикл без переменных, т.е. нельзя передать в качестве loop_var ни None, ни пустой список []. TF в такой ситуации вернет ошибку:

ValueError: No loop variables provided

Поэтому мы заведем dumb_value. В принципе, можно использовать эту переменную как счетчик цикла и отказаться от global_step, а можно не использовать совсем, как это сделано в коде выше. Наконец, третье, чтобы запустить train_op, мы используем функцию tf.tuple. Она возвращает список переданных ей в качестве первого параметра тензоров, но при этом убеждается, что все тензоры из списка вычислены, т.е. завершены операции отвечающие за их вычисление, при этом дополнительно принимает на вход список операций (control_inputs), которые надо выполнить до того как вернуть список тензоров. Фактически это в некотором смысле тоже самое, что tf.control_dependencies.

Результат работы данного кода будет тот же, что и в случае когда цикл организуется средствами питона, а не вносится в граф вычислений. Однако, в реальной жизни внесение основного цикла оптимизации внутрь графа может привести к некоторому количеству побочных эфектов, возникновение которых трудно будет выявить.