Тензоры tf.constant и tf.Variable. Индексирование и изменение формы

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

https://www.tensorflow.org/guide/tensor

На этом занятии мы с вами познакомимся со способом определения тензоров и некоторыми базовыми операциями с ними.

Для тех, кто ранее работал с версией Tensorflow 1.x хочу отметить, что в Tensorflow 2.x по умолчанию включен режим активного выполнения (executingeagerly). В этом можно убедиться, выполнив команду:

print(tf.executing_eagerly() )

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

Определение тензоров

Но вернемся к тензорам. В самом простом случае их можно определить как константные, неизменяемые объекты, используя функцию

tf.constant(value, dtype=None, shape=None, name="Const")

  • value – значения тензора;
  • dtype – тип данных тензора;
  • shape – размерность тензора;
  • name – имя тензора.

Например, через эту функцию можно задать тензор, содержащий одно число:

a = tf.constant(1)
print(a)

В консоли увидим, что это объект типа Tensor со значением 1, не имеющий осей и тип данных int32 – целочисленный:

tf.Tensor(1, shape=(), dtype=int32)

Отсутствие осей говорит, что ранг тензора равен нулю. Однако, если явно указать параметр shape с размерностями 1x1:

a = tf.constant(1, shape=(1,1))

то получим уже матрицу с этим размером 1x1:

tf.Tensor([[1]], shape=(1, 1), dtype=int32)

Если же создать тензор на основе одномерного списка:

b = tf.constant([1, 2, 3, 4])
print(b)

То у него автоматически появится одна ось, размером в 4 элемента:

tf.Tensor([1 2 3 4], shape=(4,), dtype=int32)

Соответственно, для двумерного списка (или матрицы):

с = tf.constant([[1, 2],
                 [3, 4],
                 [5, 6]], dtype=tf.float16)
print(с)

получим тензор ранга два (две оси):
tf.Tensor(
[[1. 2.]
 [3. 4.]
[5. 6.]], shape=(3, 2), dtype=float16)

И так далее, мы можем создавать тензоры произвольной размерности. Причем, обратите внимание, в последнем случае мы явно указали тип данных float16 (вещественный 16 бит) и при создании тензора этот тип был применен для всех его элементов. Если же тип не указывается, то он определяется автоматически, исходя из переданных данных. Отсюда следует, что все элементы в тензоре имеют единый тип данных, то есть, здесь нельзя одновременно хранить, например, числа, строки, булевы значения. Только что то одно – один тип. Также следует иметь в виду, что тензоры формируются на основе прямоугольных списков. То есть, число элементов в каждой строке матрицы должно быть одинаковым и это правило должно соблюдаться и в многомерном случае. Хотя, в Tensorflow есть возможность определять неравномерные тензоры (RaggedTensor).

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

a2 = tf.cast(a, dtype=tf.float32)

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

Курс по NumPy

Мало того, любой тензор можно преобразовать в массив NumPy, либо с помощью функции array, либо методом numpy():

import numpy as np
 
np_b1 = np.array(b)
np_b2 = b.numpy()
 
print(np_b1, np_b2, sep="\n")

Переменные tf.Variable

Если же требуется объявить тензор с изменяемыми значениями, то для этого используется класс tf.Variable. Он имеет похожий набор параметров, что и функция tf.constant() и применяется подобным образом:

b = tf.constant([1, 2, 3, 4])
 
v1 = tf.Variable(-1.2)        # переменная из одного числа
v2 = tf.Variable([4, 5, 6, 7], dtype=tf.float32) # переменная через список
v3 = tf.Variable(b)           # переменная через тензор
 
print(v1, v2, v3, sep="\n\n")

Чтобы изменить значение переменной, вызывается метод assign() с указанием других величин, например, так:

v1.assign(0)
v2.assign([0, 1, 6, 7])

Обратите внимание, что размерность обновляемых данных должна совпадать с размерностью переменной, иначе произойдет исключение (ошибка). При этом, новые данные, как правило, записываются в ту же область памяти, где хранились прежние значения.

По аналогии можно использовать методы:

v3.assign_add([1, 1, 1, 1])   # добавление значений
v1.assign_sub(5)              # вычитание значений

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

v3 = tf.Variable([-1, -2, -3, -4])

Мы также получим изменение данных, хранящихся в новом объекте, а старый будет уничтожен сборщиком мусора.

Но именно из-за этого возникает одна существенная проблема. Когда какой-либо тензор передается функции, например:

opt = tf.optimizers.SGD(learning_rate=0.1)
opt.minimize(y, [x])

то измененное таким образом значение не будет доступно за пределами этой функции (в основной программе), так как ссылки y и x будет вести к исходным объектам, а не новым. Именно поэтому, при оптимизации каких-либо изменяемых параметров, например, весов в НС, их следует определять как переменные через объект tf.Variable.

Также следует помнить, что создание новой переменной, на основе существующей:

v4 = tf.Variable(v1)

создает ее копию, то есть, ссылки v4 и v1 будут вести на разные объекты. Эту конструкцию можно использовать для клонирования переменных тензоров.

Опять же, если нам нужно преобразовать переменную в массив NumPy, то достаточно вызвать метод numpy():

print(v2.numpy())

Чтобы посмотреть размерность тензора можно воспользоваться его свойством shape:

print(v2.shape)

Возвращает кортеж с размерностями по каждой из осей:

(4,)

Индексирование и срезы

Следующий важный момент – это индексирование элементов тензоров и извлечение срезов. Эти операции реализованы аналогично массивам пакета NumPy. Поэтому за подробной информацией обращайтесь к учебному курсу на этом канале. Я не буду здесь повторяться и лишь сделаю краткий обзор по индексированию и срезам в Tensorflow.

Следует иметь в виду, что по индексам можно только читать данные из тензоров, например, так:

val_0 = v3[0]     # первый элемент
val_12 = v3[1:3]  # элементы со 2-го по 3-й [-2, -3]

Причем, переменные val_0 и val_12 будут ссылаться на те же данные, что и тензор v3, то есть, копирования информации здесь не происходит. Это очень удобно с точки зрения производительности и использования памяти, но при этом, нужно понимать, что изменение данных в val_0 и val_12 приведет к изменению и тензора v3:

val_0.assign(10)
print(v3)

Увидим значения:

<tf.Variable 'Variable:0' shape=(4,) dtype=int32, numpy=array([10, -2, -3, -4])>

то есть, через val_0 был изменен первый элемент тензора v3.

Далее, Tensorflowподдерживает списочное индексирование с помощью функции gather(), следующим образом:

x = tf.constant(range(10)) + 5
x_indx = tf.gather(x, [0, 4])
print(x, x_indx, sep="\n")

На выходе тензор x_indx будет состоять из двух элементов с индексами 0 и 4, взятыми из тензора x.

Если же мы попытаемся обратиться к вектору через список:

val_indx = v2[(1, 2)]

то это будет восприниматься как получение элемента из второй строки (индекс 1) и третьего столбца (индекс 2). Поэтому для одномерного тензора данная запись приведет к ошибке. Но если задать двумерный тензор:

v3 = tf.constant([[1, 2, 7], [3, 4, 8], [5, 6, 9]])
val_indx = v3[(1, 2)]
print(val_indx)

то в консоли увидим значение 8 (элемент из 2-й строки и 3-го столбца).

Того же самого результата можно добиться и так:

val_indx = v3[1][2]

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

val_indx = v3[0, 1]

Если же мы в качестве одного из параметров укажем только один индекс:

val_indx = v3[0]
print(val_indx)

то будет возвращена соответствующая строка (в данном случае первая). При этом, размерность тензора val_indx будет shape=(3,):

tf.Tensor([1 2 7], shape=(3,), dtype=int32)

то есть, он представляется уже одномерным массивом.

Для считывания определенного столбца целиком, следует использовать механизм срезов, например, так:

val_indx = v3[:, 1]

получим второй столбец в виде вектора [2, 4, 6].

Напомню, что срезы определяются синтаксисом:

start:stop:step

поэтому для извлечения, например, только первых двух элементов из последнего 3-го столбца можно записать:

val_indx = v3[:2, 2]

или так:

val_indx = v3[:2, -1]

Здесь отрицательные индексы определяют нумерацию с конца соответствующей оси.

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

Изменение формы тензоров

У любого тензора можно поменять форму (число элементов по осям). Например, пусть задан одномерный тензор длиной в 30 элементов:

a = tf.constant(range(30))

Его можно привести к другой форме (создать новое представление) в виде матрицы 5x6 элементов. Это делается с помощью функции tf.reshape(), следующим образом:

b = tf.reshape(a, [5, 6])
print(b.numpy())

Теперь переменная bссылается на те же самые данные, что и переменная a, но форма тензора стала другой – матрица 5x6. Так как здесь происходит лишь изменение формы тензора, то это достаточно быстрая операция и ее можно безопасно выполнять и для больших данных.

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

b = tf.reshape(a, [5, 5])

то это приведет к ошибке, так как 5x5 = 25, что меньше, чем 30 – число элементов в векторе a.

Наконец, мы можем по одной из осей не указывать размер (записывая -1), тогда он будет вычислен автоматически, исходя из общего числа элементов:

b = tf.reshape(a, [6, -1])

Получим матрицу 6x5.

Функция reshape() позволяет менять форму тензора, но не порядок следования элементов. Это значит, что она не подходит, например, для операции транспонирования, когда строки и столбцы меняются между собой. Для этого следует использовать специальную функцию:

b_T = tf.transpose(b, perm=[1, 0])

Здесь параметр permуказывает по каким осям проводить операцию транспонирования.

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

Видео по теме