Примеры использования Python-библиотеки NumPy

30 июля 2018

Я много слышал про библиотеку NumPy, что дескать в ней есть много полезных математических функций, или что-то в этом роде. Однако какой-то практической задачи, где был бы нужен NumPy, мне как-то не подворачивалось, и потому большого интереса изучать данную библиотеку у меня не было. Потом я заинтересовался Software Defined Radio, и, как следствие, цифровой обработкой сигналов. И вот тут иногда возникает желание по-быстрому поиграться в REPL, скажем, с быстрым преобразованием Фурье (Fast Fourier Transform, FFT) или чем-то таким. Оказалось, что это задача как раз для NumPy. Поэтому было решено разобраться в возможностях этой библиотеки как следует, выяснив заодно, что в ней еще есть помимо FFT.

Простой пример: как выглядит функция?

Для начала попробуем понять общую идею, стоящую за NumPy. Главным объектом, которым оперирует NumPy, является массив. Например, следующий код объявляет массив целых чисел от -5 до 5:

>>> import numpy as np
>>> x = np.linspace(-5, 5, 11)
>>> x
array([-5., -4., -3., -2., -1.,  0.,  1.,  2.,  3.,  4.,  5.])

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

>>> x - x
array([0., 0., 0., 0., 0., 0., 0., 0., 0., 0., 0.])
>>> x * x
array([25., 16.,  9.,  4.,  1.,  0.,  1.,  4.,  9., 16., 25.])

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

>>> 10 + 2 * x
array([ 0.,  2.,  4.,  6.,  8., 10., 12., 14., 16., 18., 20.])

А еще массивы имеют разные полезные методы, например:

>>> (x / 2).round()
array([-2., -2., -2., -1., -0.,  0.,  0.,  1.,  2.,  2.,  2.])

Само собой разумеется, массивы NumPy можно преобразовывать в обычные списки языка Python, и обратно:

>>> y = np.array([1,2,3])
>>> y
array([1, 2, 3])
>>> y.tolist()
[1, 2, 3]

Вы спросите, а в чем практическая польза таких массивов? Допустим, мы хотим по-быстрому посмотреть, как выглядит график какой-то функции. Для определенности возьмем сигмоиду, часто используемую в качестве функции активации в нейронных сетях. Рисовать будем при помощи уже знакомой нам библиотеки Matplotlib.

С NumPy код получается крайне простым:

# vim: set ai et ts=4 sw=4:

import matplotlib.pyplot as plt
import numpy as np

x = np.linspace(-5, 5, 100)

def sigmoid(alpha):
    return 1 / ( 1 + np.exp(- alpha * x) )

dpi = 80
fig = plt.figure(dpi = dpi, figsize = (512 / dpi, 384 / dpi) )

plt.plot(x, sigmoid(0.5), 'ro-')
plt.plot(x, sigmoid(1.0), 'go-')
plt.plot(x, sigmoid(2.0), 'bo-')

plt.legend(['A = 0.5', 'A = 1.0', 'A = 2.0'], loc = 'upper left')

fig.savefig('sigmoid.png')

Результат:

Сигмоид, нарисованный при помощи NumPy и Matplotlib

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

Всякая статистика

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

Сгенерим немного случайных данных:

>>> import numpy as np
>>> ones = np.ones(50)
>>> rnd = np.random.random(50) * 0.1
>>> samples = ones + rnd

Посчитаем среднее:

>>> np.average(samples)
1.0456167098847502
>>> np.mean(samples)
1.0456167098847502

Медиану:

>>> np.median(samples)
1.0446054794173638

Процентили:

>>> np.percentile(samples, 50)
1.0446054794173638
>>> np.percentile(samples, 95)
1.0957859973475361
>>> np.percentile(samples, 99)
1.0973855545421

Максимум, минимум, peak-to-peak:

>>> samples.max()
1.0967179044332522
>>> samples.min()
1.0060578855891802
>>> samples.ptp()
0.09066001884407204

А заодно уж и стандартное отклонение с дисперсией:

>>> np.std(samples)
0.027949661469244255
>>> np.var(samples)
0.000781183576245357

Использованная выше функция np.random.random генерирует случайные числа с равномерным распределением. А если мы хотели бы использовать нормальное распределение? Нет проблем:

>>> import matplotlib.pyplot as plt
>>> samples = np.random.normal(loc=0, scale=5, size=100000)
>>> plt.hist(samples, 200)
>>> plt.show()

Помимо нормального и равномерного распределения NumPy также предлагает beta, dirichlet, gamma, binomial, exponential, и другие.

Линейная алгебра

До сих пор мы рассматривали одномерные массивы. Когда массив двумерный, он фактически становится матрицей. А там где матрицы, там и линейная алгебра.

Объявим несколько матриц:

>>> import numpy.matlib
>>> import numpy.linalg
>>> m1 = np.arange(1, 10).reshape(3,3)
>>> m1
array([[1, 2, 3],
       [4, 5, 6],
       [7, 8, 9]])
>>> m2 = np.identity(3)
>>> m2
array([[1., 0., 0.],
       [0., 1., 0.],
       [0., 0., 1.]])

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

>>> m1.transpose()
array([[1, 4, 7],
       [2, 5, 8],
       [3, 6, 9]])
>>> m2.trace()
3.0
>>> np.linalg.det(m2)
1.0

Матрицы можно складывать, умножать на число, умножать на вектор, а также умножать на другую матрицу:

>>> m1 + m2
array([[ 2.,  2.,  3.],
       [ 4.,  6.,  6.],
       [ 7.,  8., 10.]])
>>> m1 * 3
array([[ 3,  6,  9],
       [12, 15, 18],
       [21, 24, 27]])
>>> m1 + np.array([1,2,3])
array([[ 2,  4,  6],
       [ 5,  7,  9],
       [ 8, 10, 12]])
>>> m1 * m2
array([[1., 0., 0.],
       [0., 5., 0.],
       [0., 0., 9.]])

Посчитать матрицу, обратную к данной, можно функцией np.linalg.inv:

>>> m3 = np.matlib.rand(3, 3)
>>> (m3 * np.linalg.inv(m3))
matrix([[1.0000000e+00, 0.0000000e+00, 0.0000000e+00],
        [0.0000000e+00, 1.0000000e+00, 0.0000000e+00],
        [4.4408921e-16, 0.0000000e+00, 1.0000000e+00]])
>>> (m3 * np.linalg.inv(m3)).round()
matrix([[1., 0., 0.],
        [0., 1., 0.],
        [0., 0., 1.]])

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

FFT и обратный FFT

Казалось бы, в начале поста была сказано про FFT, а в итоге вместо FFT мы говорим про какие-то там массивы и матрицы. Ладно, пожалуйста, вот вам FFT:

#!/usr/bin/env python3

import numpy as np
import matplotlib.pyplot as plt

dpi = 80

tau = 2 * np.pi
t = np.linspace(0, 0.5, 500)
x_1 = 0.9 * np.sin(100 * tau * t)
x_2 = 0.4 * np.sin(150 * tau * t)
x = x_1 + x_2
N = x.size

# x = x * np.hamming(N)

fig = plt.figure(dpi = dpi, figsize = (512 / dpi, 384 / dpi) )
plt.plot(t, x)
plt.xlabel("Time (s)")
plt.ylabel("Amplitude")
fig.savefig("x.png")
plt.close()

X = np.fft.fft(x)

# t[1] - t[0] = sample rate
# 1/(t[1] - t[0]) = frequency
freq = np.linspace(0, 1 / (t[1] - t[0]), N)[: (N // 2)]

# 1 / N is a normalization factor
X_amp = (1/N) * np.abs(X)[: (N // 2)]
X_phs = (1/N) * np.angle(X)[: (N // 2)]

fig = plt.figure(dpi = dpi, figsize = (512 / dpi, 384 / dpi) )
plt.bar(freq, X_amp)
plt.xlabel("Frequency (Hz)")
plt.ylabel("Amplitude")
fig.savefig("X_amp.png")
plt.close()

fig = plt.figure(dpi = dpi, figsize = (512 / dpi, 384 / dpi) )
plt.bar(freq, X_phs)
plt.xlabel("Frequency (Hz)")
plt.ylabel("Amplitude")
fig.savefig("X_phs.png")
plt.close()

fig = plt.figure(dpi = dpi, figsize = (512 / dpi, 384 / dpi) )
x2 = np.fft.ifft(X)
plt.plot(t, x2.real)
plt.ylabel("Amplitude")
plt.xlabel("Time (s)")
fig.savefig("x2.png")
plt.close()

Исходный сигнал:

FFT в NumPy: исходный сигнал

Его разложение на частоты:

FFT в NumPy: разложение сигнала на частоты

К сожалению, объяснение преобразования Фурье выходит за рамки данного поста. Однако по этой теме есть масса источников в интернете. В частности, можно порекомендовать статью Understanding the Fourier Transform by example в блоге ritchievink.com. Приведенный выше отрывок кода основан на коде из этой статьи.

Заключение

Как видите, библиотека NumPy оказалась крайне полезной в целом ряде задач. И это мы рассмотрели далеко не все ее возможности. В качестве источников дополнительной информации можно порекомендовать книги, NumPy их посвящено больше одной. Я лично читал «NumPy Beginners Guide, Third Edition» за авторством Ivan Idris, она оказалась вполне приличной.

Считается, что NumPy потребляет меньше памяти и работает до 10 раз быстрее аналогичного кода, написанного на Python. В этом посте я решил не приводить никаких бенчмарков, потому что тут есть масса нюансов, и все сильно зависит от специфики конкретного приложения (объемы данных, размеры мыссивов, и т.д.). Заинтересованные читатели могут изучить вопрос о производительности NumPy в качестве домашнего задания.

Еще в качестве домашнего задания рекомендую взять два сигнала, один с частотой 250 Гц, и второй с частотой 50 Гц. Перемножьте эти сигналы. Как выглядит FFT результата? Как он изменяется при изменении частоты перемножаемых сигналов? За основу возьмите последний из приведенных скриптов.

Дополнение: Еще примеры использования NumPy вы найдете в статьях Передача изображений в SSB-сигнале с помощью Python и Рисуем диаграммы Вольперта-Смита на Python. Кое-какие пояснения по поводу работы преобразования Фурье вы найдете в Шпаргалке по математике, связанной с I/Q-сигналами и Шпаргалке по работе DSP фильтров.

Метки: .


Вы можете прислать свой комментарий мне на почту, или воспользоваться комментариями в Telegram-группе.