Основы прикладной математики и информатики

Алла Тамбовцева, НИУ ВШЭ

Массивы numpy и характеристики дискретных случайных величин

Сегодня мы познакомимся с библиотекой numpy (сокращение от numeric Python), которая часто используется в задачах, связанных с машинным обучением и построением статистических моделей.

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

In [2]:
# все работает

D = [[1, 3, 6], ['a', 'b', 'c']]
print(D)
[[1, 3, 6], ['a', 'b', 'c']]

Чем хороши массивы numpy? Почему обычных списков недостаточно? Во-первых, обработка массивов занимает меньше времени (а их хранение меньше памяти), что очень актуально в случае работы с большими объемами данных. Во-вторых, функции numpy являются векторизованными ‒ их можно применять сразу ко всему массиву, то есть поэлементно. В этом смысле работа с массивами напоминает работу с векторами в R. Если в R у нас есть вектор c(1, 2, 5), то, прогнав строчку кода c(1, 2, 5)**2, мы получим вектор, состоящий из квадратов значений: c(1, 4, 25). Со списками в Python такое проделать не получится: понадобятся циклы или списковые включения. Зато с массивами numpy ‒ легко, и без всяких циклов! И в этом мы сегодня убедимся.

Для начала импортируем библиотеку (и сократим название до np для удобства):

In [1]:
import numpy as np

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

In [2]:
X = np.array([-5, 0, 1, 2]) # создаем массив X
X
Out[2]:
array([-5,  0,  1,  2])

Массивы numpy удобно использовать для операций со случайными величинами: например, можно сохранить значения дискретной случайной величины в один массив, соответствующие им вероятности – в другой, а далее перемножать эти массивы поэлементно, получая необходимые значения для расчета математического ожидания и дисперсии. Значения X мы уже сохранили выше, а теперь создадим массив p с вероятностями случайной величины X:

In [3]:
p = np.array([0.2, 0.4, 0.1, 0.3])
p
Out[3]:
array([0.2, 0.4, 0.1, 0.3])

Теперь мы можем посчитать математическое ожидание случайной величины X. Для этого необходимо попарно перемножить элементы массивов X и p:

In [4]:
X * p # четыре произведения
Out[4]:
array([-1. ,  0. ,  0.1,  0.6])

А затем сложить результаты:

In [5]:
Ex = sum(X * p) # sum - функция для суммирования
Ex
Out[5]:
-0.30000000000000004

Получили математическое ожидание равное $-0.3$, то есть $E(X)=-0.3$.

Дополнение. Для тех, кто помнит про скалярное произведение (хотя бы способ его расчета, без геометрической интерпретации): можно заметить, что если мы будем считать список значений $X$ и список значений $p$ векторами, то математическое ожидание в таком контексте – ни что иное как скалярное произведение этих векторов. И в numpy есть специальная функция для поиска скалярного произведения:

In [6]:
np.dot(X, p) # dot - от английского термина dot product (скалярное произведение)
Out[6]:
-0.30000000000000004

Как можно заметить, результаты совпадают. Другой вопрос: почему ответ такой странный? Если бы мы выполняли аналогичные действия вручную, у нас получился бы красивый ответ $-0.3$. Проблема в том, что числа с плавающей точкой (float, дробные числа) в Python хранятся в несколько ином виде по сравнению с тем, что мы видим на экране. Импортируем функцию Decimal() из библиотеки decimal, чтобы посмотреть на представление чисел с плавающей точкой внутри Python:

In [8]:
from decimal import Decimal
Decimal(-0.3)
Out[8]:
Decimal('-0.299999999999999988897769753748434595763683319091796875')

Еще лучше: теперь число $-0.3$ не похоже ни на $-0.3$, ни на результат выше! К сожалению, так будет всегда. Коварство чисел с плавающей точкой заключается еще в том, что они приводят к получению неожиданного результата при округлении. Округлим число $2.525$ до второго знака после запятой:

In [9]:
round(2.525, 2) # округление до 2 знака после запятой
Out[9]:
2.52

По правилам арифметики это число должно было округлиться до $2.53$, но этого не произошло. И дело вовсе не в том, что функция round() округляет в меньшую сторону. Убедимся в этом:

In [10]:
round(2.66, 1)
Out[10]:
2.7

Дело опять в том, что число $2.525$ в Python хранится несколько иначе:

In [11]:
Decimal(2.525)
Out[11]:
Decimal('2.524999999999999911182158029987476766109466552734375')

Если мы посмотрим на это число, то увидим, что на третьем месте после запятой стоит $4$, а не $5$, что и приводит к округлению до $2.52$ по правилам арифметики.