Введение в NumPy (1)

Главный объект 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])

Теперь у нас есть одномерный массив, т.е. у него всего одна ось вдоль которой происходит индексирование его элементов.

image
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]])

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

image

Глядя на картинку, становится понятно, что первая ось (и индекс соответственно) - это строки, вторая ось - это столбцы. Т.е. получить элемент 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([[ 5,  2,  5, 13,  6,  2],
       [ 0,  2, 14,  7,  6,  5],
       [ 6,  4,  9,  4,  4, 14],
       [13,  9,  5, 10,  9,  1]])

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

a.min()
0

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

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

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

a.mean(axis=0)    #  среднее по столбцам
array([6.  , 4.25, 8.25, 8.5 , 6.25, 5.5 ])
np.std(a, axis=1)    #  стандартное отклонение по строкам
array([3.6855574 , 4.42216639, 3.67045259, 3.84779879])

Что насчет вычислений, их скорости и занимаемой памяти?

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

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]]])

Почему именно трехмерный?

На самом деле реальный мир вовсе не ограничивается таблицами, векторами и матрицами.

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

image

Визуализация (и хорошее воображение) позволяет сразу догадаться, как устроена индексация трехмерных массивов. Например, если нам нужно вытащить из данного массива число 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
8

ndarray.itemsize возвращает размер элемента в байтах.

Теперь мы можем узнать сколько “весит” наш массив:

a.size * a.itemsize
384

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

a.dtype
dtype('int64')

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

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

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

Создание массивов в NumPy

И так, массив может быть создан из обычного списка или кортежа Python с использованием функции array().

Причем тип полученного массива зависит от типа элементов последовательности:

import numpy as np

a = np.array([1, 2, 3])
a
array([1, 2, 3])
a.dtype
dtype('int64')
a = np.array([1.1, 2.2, 3.3])
a
array([1.1, 2.2, 3.3])
a.dtype
dtype('float64')
a = np.array([1 + 2j, 2 + 3j])
a.dtype
dtype('complex128')
a = np.array((1, 2, 3))
a
array([1, 2, 3])

Функция array() преобразует последовательности последовательностей в двумерные массивы, а последовательности последовательностей, которые тоже состоят из последовательностей в трехмерные массивы.

То есть уровень вложенности исходной последовательности определяет размерность получаемого массива:

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

       [[ 5,  6],
        [ 7,  8]],

       [[ 9, 10],
        [11, 12]]])
a.ndim    #  Количество осей массива
2
b.ndim
3

Очень часто возникает задача создания массива определенного размера, причем, чем заполнен массив абсолютно неважно.

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

Функция zeros заполняет массив нулями, функция ones - единицами, а функция empty - случайными числами, которые зависят от состояния памяти.

По умолчанию, тип создаваемого массива - float64.

np.zeros((3,3))
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])
np.ones((3,3))
array([[1., 1., 1.],
       [1., 1., 1.],
       [1., 1., 1.]])
np.ones((3,3), dtype=complex)  #  Можно изменить тип массива
array([[1.+0.j, 1.+0.j, 1.+0.j],
       [1.+0.j, 1.+0.j, 1.+0.j],
       [1.+0.j, 1.+0.j, 1.+0.j]])
np.empty([3, 3])
array([[0., 0., 0.],
       [0., 0., 0.],
       [0., 0., 0.]])

Для создания последовательностей чисел NumPy предоставляет функцию arange, которая возвращает одномерные массивы:

np.arange(10)    #  От 0 до указанного числа
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
np.arange(10, 20)    #  Диапазон
array([10, 11, 12, 13, 14, 15, 16, 17, 18, 19])
np.arange(20, 100, 10)    #  Диапазон с заданным шагом
array([20, 30, 40, 50, 60, 70, 80, 90])
np.arange(0, 1, 0.1)    #  Аргументы могут иметь тип float
array([0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])

Если функция arange используется с аргументами типа float, то предсказать количество элементов в возвращаемом массиве не так-то просто.

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

Функция linspace, так же как и arange принимает три аргумента, но третий аргумент, как раз и указывает количество чисел в диапазоне.

np.linspace(0, 1, 5)
array([0.  , 0.25, 0.5 , 0.75, 1.  ])
np.linspace(0, 1, 7)
array([0.        , 0.16666667, 0.33333333, 0.5       , 0.66666667,
       0.83333333, 1.        ])
np.linspace(10, 100, 5)
array([ 10. ,  32.5,  55. ,  77.5, 100. ])

Функция linspace удобна еще и тем, что может быть использована для вычисления значений функций на заданном множестве точек:

x = np.linspace(0, 2*np.pi, 10)
x
array([0.        , 0.6981317 , 1.3962634 , 2.0943951 , 2.7925268 ,
       3.4906585 , 4.1887902 , 4.88692191, 5.58505361, 6.28318531])
y1 = np.sin(x)
y1
array([ 0.00000000e+00,  6.42787610e-01,  9.84807753e-01,  8.66025404e-01,
        3.42020143e-01, -3.42020143e-01, -8.66025404e-01, -9.84807753e-01,
       -6.42787610e-01, -2.44929360e-16])
y2 = np.cos(x)
y2
array([ 1.        ,  0.76604444,  0.17364818, -0.5       , -0.93969262,
       -0.93969262, -0.5       ,  0.17364818,  0.76604444,  1.        ])

Вывод массивов на экран

Чтобы быстрее разобраться с примерами печати массивов воспользуемся методом ndarray.reshape(), который позволяет изменять размеры массивов.

Одномерные массивы в NumPy печатаются в виде строк:

a = np.arange(10)    #  Одномерный массив
print(a)
[0 1 2 3 4 5 6 7 8 9]

Двумерные массивы печатаются в виде матриц:

b = np.arange(16).reshape(4, 4)    #  Двумерный массив
print(b)
[[ 0  1  2  3]
 [ 4  5  6  7]
 [ 8  9 10 11]
 [12 13 14 15]]

Трехмерные массивы печатаются в виде списка матриц, которые разделены пустой строкой:

c = np.arange(30).reshape(5, 2, 3)    #  Трехмерный массив
print(c)
[[[ 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]]]

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

В случае, если массив очень большой (больше 1000 элементов), NumPy печатает только начало и конец массива, заменяя его центральную часть многоточием.

print(np.arange(1001))
[   0    1    2 ...  998  999 1000]
print(np.arange(1000000))
[     0      1      2 ... 999997 999998 999999]
print(np.arange(1000000).reshape(1000,1000))
[[     0      1      2 ...    997    998    999]
 [  1000   1001   1002 ...   1997   1998   1999]
 [  2000   2001   2002 ...   2997   2998   2999]
 ...
 [997000 997001 997002 ... 997997 997998 997999]
 [998000 998001 998002 ... 998997 998998 998999]
 [999000 999001 999002 ... 999997 999998 999999]]

Если необходимо выводить весь массив целиком, то такое поведение печати можно изменить с помощью set_printoptions.

np.set_printoptions(threshold=np.nan)

Файловый ввод и вывод массивов

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

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

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

Двоичные файлы NumPy (.npy, .npz)

NumPy имеет два собственных формата файлов .npy - для хранения массивов без сжатия и .npz - для предварительного сжатия массивов.

Если массивы, которые необходимо сохранить являются небольшими, то можно воспользоваться функцией numpy.save(). В самом простом случае, данная функция принимает всего два аргумента - имя файла в который будет сохранен массив и имя самого сохраняемого массива. Однако следует помнить, что файл будет сохранен, в той директории в которой происходит выполнение скрипта Python или в указанном месте:

import numpy as np

a = np.arange(12).reshape(3, 4)
a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])
b = np.arange(16).reshape(4, 4)
b
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11],
       [12, 13, 14, 15]])
#  Файл сохранится в той же папке что и исполняемый скрипт
np.save('example_1', a)

После того как массив сохранен, его можно загрузить из файла с помощью функции numpy.load(), указав в виде строки имя необходимого файла, если он находится в той же директории, что и выполняемый скрипт Python, или путь к нему, если он располагается в другом месте:

import numpy as np

a = np.load('example_1.npy')
a
array([[ 0,  1,  2,  3],
       [ 4,  5,  6,  7],
       [ 8,  9, 10, 11]])

Файлы .npy удобны для хранения одного массива, если в одном файле нужно сохранить несколько массивов, то необходимо воспользоваться функцией numpy.savez(), которая сохранит их в несжатом виде в файле NumPy с форматом .npz.

a = np.array([1, 2, 3])
b = np.array([[1, 1], [0, 0]])
c = np.array([[1], [2], [3]])

np.savez('example_2', a, b, c)

После сохранения массивов в файл .npz они могут быть загружены с помощью, уже знакомой нам функции numpy.load(). Однако, имена массивов теперь изменились с a, b и c на arr_0, arr_1 и arr_2 соответственно:

ex_2 = np.load('example_2.npz')
ex_2.files
['arr_0', 'arr_1', 'arr_2']
ex_2['arr_0']
array([1, 2, 3])
ex_2['arr_1']
array([[1, 1],
       [0, 0]])
ex_2['arr_2']
array([[1],
       [2],
       [3]])

Что бы вместе с массивами сохранялись их оригинальные имена, необходимо в функции numpy.savez() указывать их как ключи словарей Python:

np.savez('example_2', a=a, b=b, c=c)
ex_2 = np.load('example_2.npz')
ex_2.files
['a', 'b', 'c']
ex_2['a']
array([1, 2, 3])

В случае очень больших массивов можно воспользоваться функцией numpy.savez_compressed().

a = np.arange(100000)
a
array([    0,     1,     2, ..., 99997, 99998, 99999])
#  Файл example_3.npy занимает 400 кБ на диске:
np.save('example_3', a)

#  файл example_3.npynpz занимает всего 139 кБ на диске:
np.savez_compressed('example_3', a)

На самом деле, файлы .npz это просто zip-архив который содержит отдельные файлы .npy для каждого массива.

После того как файл был загружен с помощью функции numpy.savez_compressed() его так же легко загрузить с помощью функции numpy.load():

ex_3 = np.load('example_3.npz')
ex_3.files
['arr_0']
ex_3['arr_0']
array([    0,     1,     2, ..., 99997, 99998, 99999])