3. Массивы NumPy

3.1. Прежде чем читать

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

Все примеры выполнены в консоли IDE Spyder дистрибутива Anaconda на Python версии 3.5. и NumPy версии 1.14.0. Приводимые примеры так же будут работать в любом другом дистрибутиве Python 3.х версии и последней версией пакета NumPy. Но если некоторые примеры все же не работают, то ознакомьтесь с официальной документацией вашего дистрибутива, возможно причина связана с его особенностями.

Например, если в своем дистрибутиве вы обнаружили последнюю версию IDE Spyder, то в ней нет Python консоли, к которой привыкают многие новички, учившиеся экспериментировать с кодом в IDLE. При этом новичкам может так же показаться, что и все примеры, представленные здесь, тоже лучше выполнять в Python консоли. Но нет, Python консоль использовалась автором лишь по техническим причинам, которые связаны с редактурой, версткой и дизайном кода. Консоль IPython имеет гораздо больше преимуществ.

3.2. Основы

Главный объект NumPy - это однородный многомерный массив. Чаще всего это одномерная последовательность или двумерная таблица, заполненные элементами одного типа, как правило числами, которые проиндексированы кортежем положительных целых чисел. В NumPy, элементы этого кортежа называются осями, а число осей рангом.

Что бы перейти к примерам, сначала выполним импорт пакета:

>>> import numpy as np

Импортирование numpy под псевдонимом np уже стало общепринятой, негласной, договоренностью, можно сказать, традицией.

Теперь мы може приступить к примерам. Способов создания массивов NumPy довольно много, но мы начнем с самого тривиального - создание массива из заполненного вручную списка Python:

>>> a = np.array([11, 22, 33, 44, 55, 66, 77, 88, 99])
>>> 
>>> a
array([11, 22, 33, 44, 55, 66, 77, 88, 99])

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

одномерный массив numpy

Получить доступ к числу 33 можно привычным способом:

>>> a[2]
33

В общем-то, можно подумать, что ничего интересного и нет в этих массивах, но на самом деле это только начало кроличьей норы. Оцените:

>>> a[[7, 0, 3, 3, 3, 0, 7]]
array([88, 11, 44, 44, 44, 11, 88])

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

>>> a[a > 50]
array([55, 66, 77, 88, 99])

Цель этих двух примеров - не устраивать головоломку, а продемонстрировать расширенные возможности индексирования массивов NumPy. С тем как устроена индексация мы разберемся в другой главе. Что еще интересного можно продемонстрировать? Векторизованные вычисления:

>>> 2*a + 10
array([ 32,  54,  76,  98, 120, 142, 164, 186, 208])
>>> 
>>> np.sin(a)**2 + np.cos(a)**2
array([1., 1., 1., 1., 1., 1., 1., 1., 1.])

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

>>> a = np.arange(12)
>>> a
array([ 0,  1,  2,  3,  4,  5,  6,  7,  8,  9, 10, 11])
>>> 
>>> a = a.reshape(3, 4)
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])

Сейчас мы создали массив с помощью функции np.arange(), которая во многом аналогична функции range() языка Python. Затем, мы изменили форму массива с помощью метода reshape(), т.е. на самом деле создать этот массив мы могли бы и одной командой:

>>> a = np.arange(12).reshape(3, 4)
>>> a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])

Визуально, данный массив выглядит следующим образом:

одномерный массив numpy

Глядя на картинку, становится понятно, что первая ось (и индекс соответственно) - это строки, вторая ось - это столбцы. Т.е. получить элемент 9 можно простой командой:

>>> a[2][1]    #  равносильно команде a[2, 1]
9

Снова можно подумать, что ничего нового - все как в Python. Да, так и есть, и, это круто! Еще круто, то что NumPy добавляет к удобному и привычному синтаксису Python, весьма удобные трюки, например - транслирование массивов:

>>> b = [2, 3, 4, 5]
>>> 
>>> a*b
array([[ 0,  3,  8, 15],
       [ 8, 15, 24, 35],
       [16, 27, 40, 55]])

В данном примере, без всяких циклов (и генераторов), мы умножили каждый столбец из массива a на соответствующий элемент из массива b. Т.е. мы как бы транслировали (в какой-то степени можно сказать - растянули) массив b по массиву a.

То же самое мы можем проделать с каждой строкой массива a:

>>> c = [[10], [20], [30]]
>>> 
>>> a + c
array([[10, 11, 12, 13],
       [24, 25, 26, 27],
       [38, 39, 40, 41]])

В данном случае мы просто прибавили к массиву a массив-столбец c. И получили, то что хотели. Сейчас мы не будем подробно рассматривать механизм транслирования - это тема другой главы. Вместо этого я хочу отметить, что при работе с двумерными или трехмерными массивами, особенно с массивами большей размерности, становится очень важным удобство работы с элементами массива, которые расположены вдоль отдельных измерений - его осей.

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

>>> a = np.random.randint(0, 15, size = (4, 6))
>>> a
array([[ 9, 12,  5,  3,  1,  7],
       [ 2, 12, 10, 11, 14,  9],
       [ 4,  4,  9, 11,  5,  2],
       [12,  8,  6,  8,  9,  3]])

Минимальный элемент в данном массиве это:

>>> a.min()
1

А вот минимальные элементы по столбцам и строкам:

>>> a.min(axis = 0)    #  минимальные элементы по столбцам
array([2, 4, 5, 3, 1, 2])
>>> 
>>> a.min(axis = 1)    #  минимальные элементы по строкам
array([1, 2, 2, 3])

Такое поведение заложено практически во все функции и методы NumPy:

>>> a.mean(axis = 0)    #  среднее по столбцам
array([6.75, 9.  , 7.5 , 8.25, 7.25, 5.25])
>>> 
>>> np.std(a, axis = 1)    #  стандартное отклонение по строкам
array([3.67045259, 3.77123617, 3.13138237, 2.74873708])

Чтож, мы рассмотрели одномерные и двумерные массивы, а так же некоторые трюки NumPy. Но данный пакет позиционируется прежде всего как научный инструмент. Что насчет вычислений, их скорости и занимаемой памяти?

Для примера, создадим трехмерный массив:

>>> a = np.arange(48).reshape(4, 3, 4)
>>> a
array([[[ 0,  1,  2,  3],
        [ 4,  5,  6,  7],
        [ 8,  9, 10, 11]],

       [[12, 13, 14, 15],
        [16, 17, 18, 19],
        [20, 21, 22, 23]],

       [[24, 25, 26, 27],
        [28, 29, 30, 31],
        [32, 33, 34, 35]],

       [[36, 37, 38, 39],
        [40, 41, 42, 43],
        [44, 45, 46, 47]]])

Почему именно трехмерный? На самом деле реальный мир вовсе не ограничивается таблицами, векторами и матрицами. Еще существуют тензоры, кватернионы, октавы. А некоторые данные, гораздо удобнее представлять именно в трехмерном и четырехмерном представлении, например, биржевые торги по всем инструментам, лучше всего представлять в трехмерном виде, а торги нескольких бирж в четырехмерном. Конечно, такими сложными вычислениями занимается очень небольшое количество людей, но надо отметить, что именно эти люди двигают науку и индустрию вперед. Да и слово "сложное" можно считать синонимом "интересное. Поэтому... что-то мы отвлеклись... вот наш трехмерный массив:

одномерный массив numpy

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

>>> a[2][1][3]    #  или a[2, 1, 3]
31

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

>>> a.ndim
3

Массив a действительно трехмерный. Но иногда становится интересно, а на сколько же большой массив перед нами. Например, какой он формы, т.е. сколько элементов расположено вдоль каждой оси? Ответить позволяет метод ndarray.shape:

>>> a.shape
(4, 3, 4)

Метод ndarray.size просто возвращает общее количество элементов массива:

>>> a.size
48

Еще может встать такой вопрос - сколько памяти занимает наш массив? Иногда даже возникает такой вопрос - влезет ли результирующий массив после всех вычислений в оперативную память? Что бы на него ответить надо знать, сколько "весит" один элемент массива:

>>> a.itemsize    #  эквивалентно ndarray.dtype.itemsize
4

ndarray.itemsize возвращает размер элемента в байтах. Теперь мы можем узнать сколько "весит" наш массив:

>>> a.size*a.itemsize
192

Итого - 192 байта. На самом деле, размер занимаемой массивом памяти, зависит не только от количества элементов в нем, но и от испльзуемого типа данных:

>>> a.dtype
dtype('int32')

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

>>> b = a/3.14
>>> 
>>> b
array([[[ 0.        ,  0.31847134,  0.63694268,  0.95541401],
        [ 1.27388535,  1.59235669,  1.91082803,  2.22929936],
        [ 2.5477707 ,  2.86624204,  3.18471338,  3.50318471]],

       [[ 3.82165605,  4.14012739,  4.45859873,  4.77707006],
        [ 5.0955414 ,  5.41401274,  5.73248408,  6.05095541],
        [ 6.36942675,  6.68789809,  7.00636943,  7.32484076]],

       [[ 7.6433121 ,  7.96178344,  8.28025478,  8.59872611],
        [ 8.91719745,  9.23566879,  9.55414013,  9.87261146],
        [10.1910828 , 10.50955414, 10.82802548, 11.14649682]],

       [[11.46496815, 11.78343949, 12.10191083, 12.42038217],
        [12.7388535 , 13.05732484, 13.37579618, 13.69426752],
        [14.01273885, 14.33121019, 14.64968153, 14.96815287]]])
>>> 
>>> 
>>> b.dtype
dtype('float64')

Теперь у нас есть еще один массив - массив b и его тип данных 'float64' - вещественные числа (числа с плавающей точкой) длинной 64 бита. А его размер:

>>> b.size*b.itemsize
384

Тогда массив a - 192 байта, массив b - 384 байта. А в общем, получается, 576 байт - что очень мало для современных объемов оперативной памяти, но и реальные объемы данных, которые сейчас приходится обрабатывать совсем немаленькие.

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


3.3. Напоследок

Если вы новичок, то очень скоро поймете, что в использовании NumPy так же прост как и Python. Но, рано или поздно, дело дойдет до сложных задач и вот тогда начнется самое интересное: документации не хватает, ничего не гуглится, а бесчисленные "почти" подходящие советы приводят к необъяснимым сверхъестественным последствиям. Что делать в такой ситуации?

  1. гуглить упорнее и спускаться к самому дну поисковой выдачи;
  2. гуглить на английском языке, потому что, на английском информации на порядки больше чем на русском;
  3. если не помог пункт 2, то это означает, что вы просто маньяк какой-то, и что бы решить свою маниакальную задачу, вам придется гуглить на китайском языке, потому что на китайском информации на порядки больше чем на английском.

Это шутка и серьезная рекомендация одновременно. Но, если говорить абсолютно серьезно, то просто придерживайтесь здравого смысла. Где этот здравый смысл начинается, а где заканчивается в конкретной задаче сказать очень трудно. import this вам в помощь:

>>> import this
The Zen of Python, by Tim Peters

Beautiful is better than ugly.
Explicit is better than implicit.
Simple is better than complex.
Complex is better than complicated.
Flat is better than nested.
Sparse is better than dense.
Readability counts.
Special cases aren't special enough to break the rules.
Although practicality beats purity.
Errors should never pass silently.
Unless explicitly silenced.
In the face of ambiguity, refuse the temptation to guess.
There should be one-- and preferably only one --obvious way to do it.
Although that way may not be obvious at first unless you're Dutch.
Now is better than never.
Although never is often better than *right* now.
If the implementation is hard to explain, it's a bad idea.
If the implementation is easy to explain, it may be a good idea.
Namespaces are one honking great idea -- let's do more of those!

Если вы раньше пользовались R или matlab, то вас тоже ожидает много приятных сюрпризов, по крайней мере один - придется меньше стучать по клавиатуре.