2.1 Манипулирование данными
Чтобы выполнять различные операции с данными, нам нужен способ их хранить и обрабатывать. Обычно нужно сделать две важные вещи: (1) получить данные; (2) обработать их после того, как они попали в компьютер. Без способа хранить данные получать их не имеет смысла.
Для начала познакомимся с -мерными массивами, которые также называют тензорами (tensor).
Тем, кто пользовался пакетом NumPy для Python, этот раздел будет знаком.
Какой бы фреймворк глубокого обучения ни использовался, его класс тензоров (в MXNet это ndarray, в PyTorch и TensorFlow — Tensor) похож на ndarray из NumPy.
Однако фреймворки глубокого обучения предлагают несколько важных возможностей, которых нет у ndarray из NumPy:
во-первых, они хорошо поддерживают ускоренные вычисления на GPU, тогда как NumPy работает только на CPU;
во-вторых, класс тензоров поддерживает автоматическое дифференцирование.
Эти возможности делают класс тензоров более подходящим для глубокого обучения.
Если не оговорено иное, под тензорами в этой книге понимаются экземпляры класса тензоров.
2.1.1 Начало работы
Цель этого раздела — познакомить читателя с базовыми инструментами численных вычислений, которые понадобятся при чтении книги. Если вам трудно понять некоторые математические понятия или функции библиотек, не беспокойтесь: в следующих главах эти темы будут разобраны на практических примерах. Если у вас уже есть соответствующий опыт и вы хотите глубже изучить математику, можете пропустить этот раздел.
MXNet: Сначала импортируем модуль np (numpy) и модуль npx (numpy_extension) из MXNet.
Модуль np содержит функции, которые поддерживает NumPy, а модуль npx — набор расширенных функций для глубокого обучения в среде, похожей на NumPy.
При работе с тензорами почти всегда вызывают функцию set_np: она нужна для совместимости с другими компонентами MXNet, работающими с тензорами.
PyTorch: Сначала импортируем torch. Обратите внимание: хотя фреймворк называется PyTorch, в коде используется имя torch, а не pytorch.
TensorFlow: Сначала импортируем tensorflow. Поскольку это имя довольно длинное, после импорта часто используют короткий псевдоним tf.
MXNet:
from mxnet import np, npx
npx.set_np()
PyTorch:
import torch
TensorFlow:
import tensorflow as tf
PaddlePaddle:
import warnings
warnings.filterwarnings(action='ignore')
import paddle
Тензор представляет собой массив числовых значений, который может иметь несколько измерений. Тензор с одной осью соответствует математическому понятию вектора (vector); тензор с двумя осями соответствует математической матрице (matrix); тензоры с большим числом осей специального математического названия не имеют.
MXNet: Сначала создадим вектор-строку x с помощью arange. Она содержит первые 12 целых чисел, начиная с 0; по умолчанию они создаются как числа с плавающей точкой. Каждое значение тензора называют его элементом (element). Например, в тензоре x 12 элементов. Если не указано иное, новый тензор сохраняется в оперативной памяти, а вычисления выполняются на CPU.
PyTorch: Сначала создадим вектор-строку x с помощью arange. Она содержит первые 12 целых чисел, начиная с 0; по умолчанию они создаются как целые числа, но можно указать и тип с плавающей точкой. Каждое значение тензора называют его элементом (element). Например, в тензоре x 12 элементов. Если не указано иное, новый тензор сохраняется в оперативной памяти, а вычисления выполняются на CPU.
TensorFlow: Сначала создадим вектор-строку x с помощью range. Она содержит первые 12 целых чисел, начиная с 0; по умолчанию они создаются как целые числа, но можно указать и тип с плавающей точкой. Каждое значение тензора называют его элементом (element). Например, в тензоре x 12 элементов. Если не указано иное, новый тензор сохраняется в оперативной памяти, а вычисления выполняются на CPU.
MXNet:
x = np.arange(12)
x
PyTorch:
x = torch.arange(12)
x
TensorFlow:
x = tf.range(12)
x
PaddlePaddle:
x = paddle.arange(12)
x
Форму тензора (длину вдоль каждой оси) можно получить через атрибут shape.
x.shape
Если нужно узнать только общее число элементов тензора, то есть произведение всех элементов формы, можно проверить его размер (size).
Поскольку здесь мы работаем с вектором, его shape и size по сути совпадают.
MXNet:
x.size
PyTorch:
x.numel()
TensorFlow:
tf.size(x)
PaddlePaddle:
x.numel()
Чтобы изменить форму тензора, не меняя число элементов и их значения, можно вызвать функцию reshape.
Например, можно превратить тензор x из вектора-строки формы (12,) в матрицу формы (3, 4).
Новый тензор содержит те же значения, что и исходный, но теперь он рассматривается как матрица из 3 строк и 4 столбцов.
Важно подчеркнуть: хотя форма тензора изменилась, значения его элементов не изменились.
Обратите внимание, что при изменении формы размер тензора не меняется.
MXNet, PyTorch:
X = x.reshape(3, 4)
X
TensorFlow:
X = tf.reshape(x, (3, 4))
X
PaddlePaddle:
X = paddle.reshape(x, (3, 4))
X
Не обязательно указывать каждое измерение вручную.
Если целевая форма — (высота, ширина), то, зная ширину, высоту можно вычислить автоматически, и делить самостоятельно не нужно.
В предыдущем примере, чтобы получить матрицу из 3 строк, мы вручную указали 3 строки и 4 столбца.
К счастью, можно воспользоваться автоматическим вычислением размерности, указав -1.
То есть вместо x.reshape(3, 4) можно написать x.reshape(-1, 4) или x.reshape(3, -1).
Иногда хочется инициализировать тензор нулями, единицами, другими константами или числами, случайно выбранными из определённого распределения. Например, создадим тензор формы (2, 3, 4), все элементы которого равны 0. Код выглядит так:
MXNet:
np.zeros((2, 3, 4))
PyTorch:
torch.zeros((2, 3, 4))
TensorFlow:
tf.zeros((2, 3, 4))
PaddlePaddle:
paddle.zeros((2, 3, 4))
Аналогично можно создать тензор формы (2, 3, 4), все элементы которого равны 1:
MXNet:
np.ones((2, 3, 4))
PyTorch:
torch.ones((2, 3, 4))
TensorFlow:
tf.ones((2, 3, 4))
PaddlePaddle:
paddle.ones((2, 3, 4))
Иногда значения элементов тензора удобно получить случайной выборкой из определённого распределения. Например, когда мы создаём массивы как параметры нейронной сети, их значения обычно задают случайным образом. Следующий код создаёт тензор формы (3, 4). Каждый его элемент случайно выбирается из стандартного нормального (гауссова) распределения со средним 0 и стандартным отклонением 1.
MXNet:
np.random.normal(0, 1, size=(3, 4))
PyTorch:
torch.randn(3, 4)
TensorFlow:
tf.random.normal(shape=[3, 4])
PaddlePaddle:
paddle.randn((3, 4),'float32')
Также можно задать значения элементов тензора явно, передав Python-список (или вложенный список) с числами. Здесь внешний список соответствует оси 0, а внутренние списки — оси 1.
MXNet:
np.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
PyTorch:
torch.tensor([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
TensorFlow:
tf.constant([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
PaddlePaddle:
paddle.to_tensor([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
2.1.2 Операторы
Нас интересуют не только чтение и запись данных. Мы хотим выполнять над данными математические операции. Самые простые и полезные из них — поэлементные (elementwise) операции. Они применяют стандартный скалярный оператор к каждому элементу массива. Для функций, которые принимают два массива, поэлементная операция применяет бинарный оператор к каждой паре элементов, стоящих в одинаковых позициях двух массивов. Поэлементную функцию можно построить на основе любой функции, которая переводит скаляр в скаляр.
В математической записи унарный скалярный оператор (принимающий один аргумент) обозначается как . Это значит, что функция отображает любое вещественное число () в другое вещественное число. Аналогично бинарный скалярный оператор (принимающий два аргумента) обозначается как . Для любых двух векторов и одинаковой формы и бинарного оператора мы получаем вектор . Вычисляется он так: , где , и — элементы векторов , и соответственно. Здесь мы превращаем скалярную функцию в поэлементную векторную операцию и получаем функцию , которая возвращает вектор.
Для любых тензоров одинаковой формы стандартные арифметические операторы (+, -, *, / и **) выполняются поэлементно.
Поэлементную операцию можно выполнять над любыми двумя тензорами одинаковой формы.
В примере ниже через запятую перечислены пять результатов поэлементных операций — они образуют кортеж.
MXNet:
x = np.array([1, 2, 4, 8])
y = np.array([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y # Оператор ** — возведение в степень
PyTorch:
x = torch.tensor([1.0, 2, 4, 8])
y = torch.tensor([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y # Оператор ** — возведение в степень
TensorFlow:
x = tf.constant([1.0, 2, 4, 8])
y = tf.constant([2.0, 2, 2, 2])
x + y, x - y, x * y, x / y, x ** y # Оператор ** — возведение в степень
PaddlePaddle:
x = paddle.to_tensor([1.0, 2, 4, 8])
y = paddle.to_tensor([2, 2, 2, 2])
x + y, x - y, x * y, x / y, x**y # Оператор ** — возведение в степень
Поэлементно можно применять и другие вычисления, например унарные операторы, такие как экспонента.
MXNet:
np.exp(x)
PyTorch:
torch.exp(x)
TensorFlow:
tf.exp(x)
PaddlePaddle:
paddle.exp(x)
Помимо поэлементных вычислений, можно выполнять и линейно-алгебраические операции, включая скалярное произведение векторов и умножение матриц. Ключевые положения линейной алгебры мы разберём в разделе 2.3.
Также можно объединять (concatenate) несколько тензоров, укладывая их встык в один больший тензор. Нужно лишь передать список тензоров и указать, по какой оси их соединять. В примере ниже показано, что происходит при соединении двух матриц по строкам (вдоль оси 0, первого элемента формы) и по столбцам (вдоль оси 1, второго элемента формы). Длина первого выходного тензора по оси 0 (6) равна сумме длин входных тензоров по оси 0 (); длина второго выходного тензора по оси 1 (8) равна сумме длин входных тензоров по оси 1 ().
MXNet:
X = np.arange(12).reshape(3, 4)
Y = np.array([[2, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
np.concatenate([X, Y], axis=0), np.concatenate([X, Y], axis=1)
PyTorch:
X = torch.arange(12, dtype=torch.float32).reshape((3,4))
Y = torch.tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
torch.cat((X, Y), dim=0), torch.cat((X, Y), dim=1)
TensorFlow:
X = tf.reshape(tf.range(12, dtype=tf.float32), (3, 4))
Y = tf.constant([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
tf.concat([X, Y], axis=0), tf.concat([X, Y], axis=1)
PaddlePaddle:
X = paddle.arange(12, dtype='float32').reshape((3, 4))
Y = paddle.to_tensor([[2.0, 1, 4, 3], [1, 2, 3, 4], [4, 3, 2, 1]])
paddle.concat((X, Y), axis=0), paddle.concat((X, Y), axis=1)
Иногда нужно построить двоичный тензор с помощью логических операторов.
Возьмём X == Y: в каждой позиции, где X и Y равны, соответствующий элемент нового тензора равен 1.
Это значит, что логическое выражение X == Y в этой позиции истинно, а в остальных позициях ложно, то есть равно 0.
X == Y
Суммирование всех элементов тензора даёт тензор из одного элемента.
MXNet, PyTorch, PaddlePaddle:
X.sum()
TensorFlow:
tf.reduce_sum(X)
2.1.3 Механизм broadcasting
Выше мы видели, как выполнять поэлементные операции над тензорами одинаковой формы. Даже при разных формах можно выполнять поэлементные операции, используя механизм broadcasting. Он работает так:
- Один или оба массива расширяются за счёт копирования элементов так, чтобы после преобразования оба тензора имели одинаковую форму;
- Над полученными массивами выполняется поэлементная операция.
В большинстве случаев broadcasting происходит вдоль осей длины 1, как в следующем примере:
MXNet:
a = np.arange(3).reshape(3, 1)
b = np.arange(2).reshape(1, 2)
a, b
PyTorch:
a = torch.arange(3).reshape((3, 1))
b = torch.arange(2).reshape((1, 2))
a, b
TensorFlow:
a = tf.reshape(tf.range(3), (3, 1))
b = tf.reshape(tf.range(2), (1, 2))
a, b
PaddlePaddle:
a = paddle.reshape(paddle.arange(3), (3, 1))
b = paddle.reshape(paddle.arange(2), (1, 2))
a, b
Поскольку a и b — матрицы формы и , их непосредственное сложение невозможно: формы не совпадают.
Мы расширяем (broadcast) обе матрицы до одной большей матрицы : матрица a копирует столбец, матрица b копирует строки, а затем результаты складываются поэлементно.
MXNet, PyTorch, TensorFlow, PaddlePaddle:
a + b
2.1.4 Индексация и срезы
Как и в любом другом массиве Python, к элементам тензора можно обращаться по индексам. Как и во всех массивах Python, первый элемент имеет индекс 0, а последний — индекс -1; для указания диапазона можно задавать границы, при которых включается первый элемент и исключается последний.
Так, с помощью [-1] можно выбрать последний элемент, а с помощью [1:3] — второй и третий элементы:
X[-1], X[1:3]
MXNet, PyTorch: Помимо чтения, с помощью индексов можно записывать значения в элементы матрицы.
TensorFlow: Тензоры (Tensors) в TensorFlow неизменяемы и не поддерживают присваивание.
Переменные (Variables) в TensorFlow — изменяемые контейнеры, в которые можно присваивать значения.
Помните, что в TensorFlow градиенты не распространяются обратно через Variable.
Кроме присваивания всей переменной целиком, можно записывать значения и в отдельные элементы Variable по индексу.
MXNet, PyTorch, PaddlePaddle:
X[1, 2] = 9
X
TensorFlow:
X_var = tf.Variable(X)
X_var[1, 2].assign(9)
X_var
Если нужно присвоить одно и то же значение нескольким элементам, достаточно проиндексировать все нужные элементы и присвоить им значение.
Например, [0:2, :] обращается к первой и второй строкам, где «:» означает все элементы вдоль оси 1 (столбцы).
Хотя мы говорим об индексации матриц, это работает и для векторов, и для тензоров с более чем двумя осями.
MXNet, PyTorch, PaddlePaddle:
X[0:2, :] = 12
X
TensorFlow:
X_var = tf.Variable(X)
X_var[0:2, :].assign(tf.ones(X_var[0:2,:].shape, dtype = tf.float32) * 12)
X_var
2.1.5 Экономия памяти
Некоторые операции могут приводить к выделению памяти под новый результат.
Например, если написать Y = X + Y, то Y будет указывать не на прежний тензор, а на тензор в новой области памяти.
В примере ниже мы покажем это с помощью функции id() из Python, которая возвращает адрес объекта в памяти.
После выполнения Y = Y + X видно, что id(Y) указывает на другое место.
Это происходит потому, что Python сначала вычисляет Y + X, выделяет под результат новую память, и только потом переставляет Y на эту новую область.
before = id(Y)
Y = Y + X
id(Y) == before
Такое поведение может быть нежелательно по двум причинам:
- Во-первых, нам не хочется без необходимости постоянно выделять память. В машинном обучении могут быть сотни мегабайт параметров, которые обновляются многократно в секунду. Обычно такие обновления хотелось бы выполнять на месте (in-place);
- Во-вторых, если не обновлять на месте, на старую область памяти по-прежнему будут ссылаться другие переменные, и часть кода может случайно использовать устаревшие параметры.
MXNet, PyTorch: К счастью, выполнить операцию на месте очень просто.
Можно присвоить результат операции заранее выделенному массиву с помощью срезов, например Y[:] = <выражение>.
Чтобы показать это, сначала создадим новую матрицу Z той же формы, что и Y, и с помощью zeros_like выделим блок из одних нулей.
TensorFlow: Variables — это изменяемые контейнеры в TensorFlow, которые позволяют хранить параметры модели.
Результат операции можно присвоить Variable с помощью assign.
Чтобы показать это, создадим Z той же формы, что и тензор Y, и с помощью zeros_like выделим блок из одних нулей.
MXNet:
Z = np.zeros_like(Y)
print('id(Z):', id(Z))
Z[:] = X + Y
print('id(Z):', id(Z))
PyTorch:
Z = torch.zeros_like(Y)
print('id(Z):', id(Z))
Z[:] = X + Y
print('id(Z):', id(Z))
TensorFlow:
Z = tf.Variable(tf.zeros_like(Y))
print('id(Z):', id(Z))
Z.assign(X + Y)
print('id(Z):', id(Z))
PaddlePaddle:
Z = paddle.zeros_like(Y)
print('id(Z):', id(Z))
Z = X + Y
print('id(Z):', id(Z))
MXNet, PyTorch: Если в дальнейших вычислениях X не используется, можно также написать X[:] = X + Y или X += Y, чтобы уменьшить затраты памяти на операцию.
TensorFlow: Даже если вы храните состояние в Variable, может понадобиться избегать лишнего выделения памяти под тензоры, которые не являются параметрами модели, чтобы дополнительно снизить расход памяти.
Поскольку тензоры TensorFlow неизменяемы, а градиенты не проходят через Variable, TensorFlow не предоставляет явного способа выполнить отдельную операцию на месте.
Однако TensorFlow предоставляет декоратор tf.function, который заключает вычисления в граф TensorFlow; перед запуском граф компилируется и оптимизируется.
Это позволяет TensorFlow удалять неиспользуемые значения и повторно использовать ранее выделенную память для значений, которые уже не нужны.
Так расход памяти на вычисления в TensorFlow сводится к минимуму.
MXNet, PyTorch, PaddlePaddle:
before = id(X)
X += Y
id(X) == before
TensorFlow:
@tf.function
def computation(X, Y):
Z = tf.zeros_like(Y) # Это неиспользуемое значение будет удалено
A = X + Y # Когда оно больше не нужно, память будет переиспользована
B = A + Y
C = B + Y
return C + Y
computation(X, Y)
2.1.6 Преобразование в другие объекты Python
MXNet, TensorFlow: Преобразовать тензор фреймворка глубокого обучения в NumPy-тензор (ndarray) несложно, и обратное преобразование тоже.
Результат при этом не разделяет память с исходным тензором.
Эта небольшая неприятность на самом деле очень важна: при вычислениях на CPU или GPU, если пакет NumPy в Python тоже захочет использовать тот же блок памяти для других операций, не хочется останавливать вычисления и ждать его.
PyTorch: Преобразовать тензор фреймворка в NumPy-тензор (ndarray) так же просто, и обратно.
Тензоры torch и массивы numpy разделяют одну и ту же область памяти, поэтому изменение одного из них (в том числе операцией на месте) меняет и другой.
MXNet:
A = X.asnumpy()
B = np.array(A)
type(A), type(B)
PyTorch:
A = X.numpy()
B = torch.tensor(A)
type(A), type(B)
TensorFlow:
A = X.numpy()
B = tf.constant(A)
type(A), type(B)
PaddlePaddle:
A = X.numpy()
B = paddle.to_tensor(A)
type(A), type(B)
Чтобы преобразовать тензор размера 1 в скаляр Python, можно вызвать функцию item или встроенные функции Python.
MXNet:
a = np.array([3.5])
a, a.item(), float(a), int(a)
PyTorch:
a = torch.tensor([3.5])
a, a.item(), float(a), int(a)
TensorFlow:
a = tf.constant([3.5]).numpy()
a, a.item(), float(a), int(a)
PaddlePaddle:
a = paddle.to_tensor([3.5])
a, a.item(), float(a), int(a)
Резюме
- Основной интерфейс для хранения и обработки данных в глубоком обучении — тензор (-мерный массив). Он предоставляет множество возможностей: базовые математические операции, broadcasting, индексацию, срезы, экономию памяти и преобразование в другие объекты Python.
Упражнения
- Запустите код из этого раздела. Замените условие
X == YнаX < YилиX > Yи посмотрите, какой тензор получится. - Замените два тензора, над которыми выполняется поэлементная операция с broadcasting, на тензоры другой формы (например, трёхмерные). Совпадает ли результат с вашими ожиданиями?
MXNet: Обсуждение
PyTorch: Обсуждение
TensorFlow: Обсуждение
PaddlePaddle: Обсуждение