Содержание

Философия Python заключается в том, чтобы позволить программистам выражать концепции в удобной форме и в меньшем количестве строк кода. Эта философия делает язык подходящим для разнообразного набора сценариев: простые сценарии для Интернета, большие веб-приложения (например, YouTube), язык сценариев для других платформ (например, Blender и Autodesk Maya) и научные приложения в нескольких областях, таких как астрономия, метеорология, физика и наука о данных.

Технически возможно реализовать скалярные и матричные вычисления с использованием списков Python. Однако это может быть громоздко, а производительность низка по сравнению с языками, подходящими для численных вычислений, такими как MATLAB или Fortran или даже некоторые языки общего назначения, такие как C или C++.

Чтобы обойти этот недостаток, появилось несколько библиотек, которые поддерживают простоту использования Python, одновременно предоставляя возможность выполнять численные вычисления эффективным образом. Стоит упомянуть две такие библиотеки: NumPy (одна из первых библиотек для обеспечения эффективных численных вычислений в Python) и TensorFlow (недавно развернутая библиотека, ориентированная больше на алгоритмы глубокого обучения).

  • NumPy обеспечивает поддержку больших многомерных массивов и матриц вместе с набором математических функций для работы с этими элементами. Проект полагается на хорошо известные пакеты, реализованные на других языках (например, Fortran) для выполнения эффективных вычислений,предоставляя пользователю выразительность Python и производительность, аналогичную MATLAB или Fortran.
  • TensorFlow — это библиотека с открытым исходным кодом для численных вычислений, первоначально разработанная исследователями и инженерами, работающими в команде Google Brain. Основная цель библиотеки — предоставить простой в использовании API для реализации практических алгоритмов машинного обучения и развертывания их для работы на процессорах, графических процессорах или в кластере.

Но как эти схемы сравнивать? Насколько быстрее работает приложение, если оно реализовано с помощью NumPy вместо чистого Python? А как насчет TensorFlow? Цель этой статьи — начать изучение улучшений, которых можно достичь с помощью этих библиотек.

Чтобы сравнить эффективность трех подходов, вы создадите базовую регрессию с помощью нативных Python, NumPy и TensorFlow.

Генерирование тестовых данных

Чтобы проверить производительность библиотек, вы рассмотрим простую задачу линейной регрессии с двумя параметрами. Модель имеет два параметра: точку пересечения w_0 и единственный коэффициент w_1.

Учитывая N пар входов x и желаемых выходов d, идея состоит в том, чтобы смоделировать взаимосвязь между выходами и входами с использованием линейной модели y = w_0 + w_1 \times x, где выход модели y приблизительно равен желаемому выходу d для каждой пары (x, d).

Техническая деталь: точка пересечения w_0 технически представляет собой просто коэффициент, подобный w_1, но её можно интерпретировать как коэффициент, умножающий элементы единичного вектора.

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

import numpy as np

np.random.seed (444)

N = 10000
sigma = 0.1
noise = sigma * np.random.randn (N)
x = np.linspace (0, 2, N)
d = 3 + 2 * x + noise
d.shape = (N, 1)

# Нам нужно добавить вектор-столбец единиц к `x`.
X = np.column_stack ( (np.ones (N, dtype=x.dtype), x))
print('Размеры исходных данных: ', X.shape)

Эта программа создает набор из 10 000 входов x, линейно распределенных в интервале от 0 до 2. Затем она создает набор желаемых выходов d = 3 + 2 \times x + noise, где шум берется из гауссовского распределения (нормальное распределение) с нулевым средним и стандартным отклонением \sigma = 0,1.

Создавая таким образом x и d, вы фактически устанавливаете, что оптимальное решение для w_0 и w_13 и 2 соответственно.

Xplus = np.linalg.pinv (X)
w_opt = Xplus @ d
print('Теоретические значения коэффициентов w0 и w1\n', w_opt, '\n\n')

Существует несколько методов оценки параметров w_0 и w_1 для соответствия линейной модели обучающей выборке. Одним из наиболее часто используемых является метод наименьших квадратов, который является хорошо известным решением для оценки w_0 и w_1, чтобы минимизировать квадрат ошибки e, полученный суммированием y - d для каждой обучающей выборки.

\begin{bmatrix} d_0 \\ d_1 \\ \vdots \\ d_{9999} \end{bmatrix} = \begin{bmatrix} 1 & x_0 \\ 1 & x_1 \\ \vdots \\ 1 & x_{9999} \end{bmatrix} \begin{bmatrix} w_0 \\ w_1 \end{bmatrix}

Используя этот подход, мы можем оценить w_m, используя w_{opt} = Xplus @ d, где Xplus задается псевдо-инверсией X, которая может быть вычислена с помощью numpy.linalg.pinv, в результате чего w_0 = 2,9978 и w_1 = 2,0016, что является очень близко к ожидаемым значениям w_0 = 3 и w_1 = 2.

Примечание. Использование w_opt = np.linalg.inv(X.T @ X) @ X.T @ d даст такое же решение. Для получения дополнительной информации см. Приводим уравнение линейной регрессии в матричный вид.

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

Один из наиболее часто используемых алгоритмов — градиентный спуск, который на высоком уровне состоит в обновлении коэффициентов параметров до тех пор, пока мы не придем к минимизированным потерям (или стоимости). То есть, у нас есть некоторая функция стоимости (часто среднеквадратическое отклонение — MSE) и мы вычисляем её градиент относительно сетевых коэффициентов (в данном случае параметров w_0 и w_1), учитывая размер шага mu. Выполняя это обновление много раз (во многие эпохи), коэффициенты сходятся к решению, которое минимизирует функцию стоимости.

В следующих разделах мы создадим и будем использовать алгоритмы градиентного спуска на чистом Python, NumPy и TensorFlow. Сравнение эффективность трех подходов по времени выполнения проводилось на ноутбуке с процессоре Intel Core i5 3510M 2,50 ГГц, который работает под ОС Widows 7 (это не есть система real-time, что надо иметь в виду).

Градиентный спуск в чистом Python

Давайте начнем с подхода, основанного на чистом Python, в качестве основы для сравнения с другими подходами. Функция Python ниже оценивает параметры w_0 и ww_1 с помощью градиентного спуска:

import itertools as it

def py_descent (x, d, mu, N_epochs):
    N = len (x)
    f = 2 / N

    # "Пустые" прогнозы, ошибки, веса, градиенты.
    y = [0] * N
    w = [0, 0]
    grad = [0, 0]

    for _ in it.repeat (None, N_epochs):
        # Не могу использовать генератор, потому что нам нужно
        # доступ к его элементам дважды.
        err = tuple (i - j for i, j in zip (d, y))
        grad[0] = f * sum (err)
        grad[1] = f * sum (i * j for i, j in zip (err, x))
        w = [i + mu * j for i, j in zip (w, grad)]
        y = (w[0] + w[1] * i for i in x)
    return w

Выше все сделано с помощью списков Python, синтаксиса нарезки и встроенных функций sum() и zip(). Перед прохождением каждой эпохи «пустые» контейнеры нулей инициализируются для y, w и grad.

Технические детали: py_descent выше использует itertools.repeat(), а не для _in range(N_epochs). Первый быстрее, чем второй, потому что repeat() не нужно создавать отдельное целое число для каждого цикла. Ему просто нужно обновить счетчик ссылок до None. Модуль timeit содержит пример.

Теперь найдём решение:

import time

x_list = x.tolist()
d_list = d.squeeze().tolist()  # Нужны 1d списки

# `mu` - размер шага или коэффициент масштабирования.
mu = 0.001
N_epochs = 10000

t0 = time.time()
py_w = py_descent (x_list, d_list, mu, N_epochs)
t1 = time.time()

print('Найдены коэффициенты линейной регрессии w0 и w1 (чистый Python):', py_w)

print('Время поиска решения (чистый Python): {:.2f} seconds\n\n'.format(round (t1 - t0, 2)))

При размере шага mu = 0,001 и 10 000 эпох мы можем получить довольно точную оценку w_0 и w_1. Внутри цикла for градиенты по параметрам вычисляются и используются, в свою очередь, для обновления весов, перемещаясь в противоположном направлении, чтобы минимизировать функцию стоимости MSE.

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

Истекшее время алгоритма измеряется с помощью библиотеки времени. Для оценки w_0 = 2,9598 и w_1 = 2,0329 требуется 18,65 секунды. Хотя библиотека timeit может предоставить более точную оценку времени выполнения, запустив несколько циклов и отключив сборку мусора, в этом случае достаточно простого просмотра одного запуска со временем, как вы вскоре увидите.

Использование NumPy

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

Используя NumPy, рассмотрим следующую программу для оценки параметров регрессии:

def np_descent (x, d, mu, N_epochs):
    d = d.squeeze()
    N = len (x)
    f = 2 / N

    y = np.zeros (N)
    err = np.zeros (N)
    w = np.zeros (2)
    grad = np.empty (2)

    for _ in it.repeat (None, N_epochs):
        np.subtract (d, y, out=err)
        grad[:] = f * np.sum (err), f * (err @ x)
        w = w + mu * grad
        y = w[0] + w[1] * x
    return w

np_w = np_descent (x, d, mu, N_epochs)
print('Найдены коэффициенты линейной регрессии w0 и w1 (NumPy):', np_w)

В приведенном выше блоке кода используются векторизованные операции с массивами NumPy (ndarrays). Единственный явный цикл for — это внешний цикл, в котором повторяется сама программа обучения. Понимание списков здесь отсутствует, потому что тип ndarray NumPy перегружает арифметические операторы для выполнения вычислений массива оптимальным образом.

Вы можете заметить, что есть несколько альтернативных способов решения этой проблемы. Например, вы можете использовать просто f * err @ X, где X — это 2d-массив, который включает в себя вектор-столбец из единиц, а не наш 1d x.

Однако на самом деле это не так уж и эффективно, потому что для этого требуется скалярное произведение всего столбца единиц с другим вектором (err), а мы знаем, что результатом будет просто np.sum(err). Аналогично, w[0] + w[1] * x тратит меньше вычислений, чем w * X, в этом конкретном случае.

Давайте сравним время выполнения. Как вы увидите ниже, поскольку сейчас речь идёт о долях секунды, а не о секундах, здесь для более точного получения времени выполнения нам необходим модуль timeit:

import timeit

setup = ("from __main__ import x, d, mu, N_epochs, np_descent;"
         "import numpy as np")
repeat = 5
number = 5  # Количество петель в каждом повторе

np_times = timeit.repeat ('np_descent (x, d, mu, N_epochs)', setup=setup,
                         repeat=repeat, number=number)

timeit.repeat() возвращает список. Каждый элемент — общее время, затраченное на выполнение number циклов инструкции. Чтобы получить единую оценку времени выполнения, вы можете взять среднее время для одного вызова из нижней границы списка повторов:

print('Время поиска решения (NumPy): {:.2f} seconds\n\n'.format(min(np_times) / number))

Использование TensorFlow

TensorFlow — это библиотека с открытым исходным кодом для численных вычислений, первоначально разработанная исследователями и инженерами, работающими в команде Google Brain.

Используя свой Python API, процедуры TensorFlow реализованы в виде графа вычислений, которые необходимо выполнить. Узлы на графе представляют математические операции, а ребра графа представляют собой многомерные массивы данных (также называемые тензорами), передаваемые между ними.

Во время выполнения TensorFlow берет граф вычислений и эффективно запускает его, используя оптимизированный код C++. Анализируя граф вычислений, TensorFlow может идентифицировать операции, которые могут выполняться параллельно.

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

Используя TensorFlow, рассмотрите следующую программу для оценки параметров регрессии:

import tensorflow as tf

def tf_descent (X_tf, d_tf, mu, N_epochs):
    N = X_tf.get_shape().as_list()[0]
    f = 2 / N

    w = tf.Variable (tf.zeros ( (2, 1)), name="w_tf")
    y = tf.matmul (X_tf, w, name="y_tf")
    e = y - d_tf
    grad = f * tf.matmul (tf.transpose (X_tf), e)

    training_op = tf.assign (w, w - mu * grad)
    init = tf.global_variables_initializer()

    with tf.Session() as sess:
        init.run()
        for epoch in range (N_epochs):
            sess.run (training_op)
        opt = w.eval()
    return opt

X_tf = tf.constant (X, dtype=tf.float32, name="X_tf")
d_tf = tf.constant (d, dtype=tf.float32, name="d_tf")

tf_w = tf_descent (X_tf, d_tf, mu, N_epochs)
print('Найдены коэффициенты линейной регрессии w0 и w1 (TensorFlow):', tf_w)

Когда вы используете TensorFlow, данные должны быть загружены в специальный тип данных, называемый Tensor. Тензоры отражают массивы NumPy во многих отношениях, чем они различны.

type (X_tf)

После создания тензоров из обучающих данных определяется граф вычислений:

  • Во-первых, переменный тензор w используется для хранения параметров регрессии, которые будут обновляться на каждой итерации.
  • Используя w и X_tf, выход y вычисляется с использованием матричного произведения, реализованного с помощью tf.matmul().
  • Ошибка вычисляется и сохраняется в тензоре e.
  • Градиенты вычисляются с использованием матричного подхода путем умножения транспонирования X_tf на e.
  • Наконец, обновление параметров регрессии реализовано с помощью функции tf.assign(). Она создает узел, который реализует пакетный градиентный спуск, обновление тензора следующего шага w до w - mu * grad.

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

Есть несколько разных способов инициализировать переменные и создать сеанс для выполнения вычислений. В этой программе строка init = tf.global_variables_initializer() создает узел в графе, который будет инициализировать переменные при запуске. Сеанс создается в блоке with, а init.run() используется для фактической инициализации переменных. Внутри блока with, training_op запускается для желаемого количества эпох, оценивая параметр регрессии,окончательное значение которых хранится в opt.

Вот та же структура синхронизации кода, которая использовалась с реализацией NumPy:

setup = ("from __main__ import X_tf, d_tf, mu, N_epochs, tf_descent;"
         "import tensorflow as tf")

tf_times = timeit.repeat ("tf_descent (X_tf, d_tf, mu, N_epochs)", setup=setup,
                         repeat=repeat, number=number)

print('Время поиска решения (TensorFlow):', min(tf_times) / number)

Для оценки w_0 = 2,9598553 и w_1 = 2,032969 потребовалось 2,25 секунды. Стоит отметить, что вычисления выполнялись на CPU, и производительность может быть улучшена при запуске на GPU.

Наконец, вы также можете определить функцию стоимости MSE и передать ее в функцию TensorFlow gradient(), которая выполняет автоматическое дифференцирование,нахождение вектора градиента MSE с учетом весов:

mse = tf.reduce_mean (tf.square (e), name="mse")
grad = tf.gradients (mse, w)[0]

Однако разница во времени в этом случае незначительна.

Заключение

Цель этой статьи — показать различие производительности реализации простого итеративного алгоритма оценки коэффициентов линейной регрессии на чистом Python, NumPy и TensorFlow.

Приведённые выше скрипты запускались на довольно «пожилом» ноутбуке, со следующими характеристиками:

И вот полученные результаты по времени, затраченному на выполнение алгоритмов этих трёх реализаций:

Реализация
Время выполнения
Чистый Python со списками
37,21 с
NumPy
0,68 с
TensorFlow на 1 ЦП
2,25 с

В то время как решения NumPy и TensorFlow конкурентоспособны (по CPU), реализация на чистом Python занимает далекое третье место. Хотя Python является надежным языком программирования общего назначения, его библиотеки, предназначенные для численных вычислений, всегда выиграют, когда дело доходит до больших пакетных операций с массивами.

Хотя в нашем случае пример NumPy оказался чуть быстрее, чем TensorFlow, важно отметить, что TensorFlow на самом деле более пригоден в более сложных случаях. С нашей относительно простой задачей градиентного спуска при поиске коэффициентов линейной регрессии использование TensorFlow, как говорится, равносильно «стрельбе из пушки по воробьям».

С TensorFlow, можно создавать и обучать сложные нейронные сети на сотнях или тысячах серверов с несколькими GPU.

Ссылки

По мотивам Pure Python vs NumPy vs TensorFlow Performance Comparison

Опубликовано Вадим В. Костерин

ст. преп. кафедры ЦЭиИТ. Автор более 130 научных и учебно-методических работ. Лауреат ВДНХ (серебряная медаль).

Оставьте комментарий

Ваш адрес email не будет опубликован.