Программирование на языке Python для сбора и анализа данных

Текст лекции: Щуров И.В., НИУ ВШЭ

Данный notebook является конспектом лекции по курсу «Программирование на языке Python для сбора и анализа данных» (НИУ ВШЭ, 2015-16). Он распространяется на условиях лицензии Creative Commons Attribution-Share Alike 4.0. При использовании обязательно упоминание автора курса и аффилиации. При наличии технической возможности необходимо также указать активную гиперссылку на страницу курса. Фрагменты кода, включенные в этот notebook, публикуются как общественное достояние.

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

Лекция №11: Библиотеки numpy и matplotlib

Библиотека numpy: эффективные массивы

Писать программы на Python легко и приятно. Гораздо легче и приятнее, чем на низкоуровневых языках программирования, таких как C или C++. Но, увы, чудес не бывает: за простоту написания кода мы платим скоростью его исполнения.

In [2]:
numbers = [1.2] * 10000
numbers[:10]
Out[2]:
[1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2, 1.2]

Мы создали список из 10000 чисел. Для простоты они все одинаковые, но Python об этом не знает. Как быстро мы возведём каждое из них в квадрат?

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

In [5]:
%%timeit

squares = [x**2 for x in numbers]
1000 loops, best of 3: 1.39 ms per loop

Больше миллисекунды на один проход! (Кстати, x*x будет в два раза быстрее — попробуйте!) Не очень-то быстро, на самом деле. Для «тяжелой» математики, часто возникающей при обработке больших массивов данных, хочется использовать все возможности компьютера.

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

In [6]:
import numpy as np

Главный объект, с которым мы будем работать — это np.array (на самом деле он называется np.ndarray):

In [7]:
np_numbers = np.array(numbers)
In [8]:
np_numbers
Out[8]:
array([ 1.2,  1.2,  1.2, ...,  1.2,  1.2,  1.2])

np.array — это специальный тип данных, похожий на список, но содержащий данные только одного типа (в данном случае — только вещественные числа).

In [9]:
np_numbers[3]
Out[9]:
1.2
In [10]:
len(np_numbers)
Out[10]:
10000

С математической точки зрения, np.array — это что-то, похожее на вектор. Но практически все операции выполняются поэлементно. Например, возведение в квадрат каждого элемента можно реализовать как np_numbers**2.

In [12]:
np_squares = np_numbers**2
np_squares
Out[12]:
array([ 1.44,  1.44,  1.44, ...,  1.44,  1.44,  1.44])

Посмотрим, как быстро работает эта операция:

In [11]:
%%timeit

np_squares = np_numbers**2
The slowest run took 17.01 times longer than the fastest. This could mean that an intermediate result is being cached 
100000 loops, best of 3: 5.04 µs per loop

Здесь 5 микросекунд, в 200 раз быстрее! Правда, нас предупреждают, что это может быть последствия кеширования — но в любом случае, работа с массивами чисел с помощью numpy происходит гораздо быстрее, чем с помощью обычных списков и циклов.

Давайте посмотрим на np.array более подробно.

Массивы похожи на списки…

In [53]:
from numpy import array
# чтобы не писать каждый раз np
In [54]:
q = array([4, 5, 8, 9])

Я дальше буду называть np.array массивами (в отличие от списком, которые мы так в Python не называем). Итак, можно обращаться к элементам массива по индексам, как и к спискам.

In [33]:
q[0]
Out[33]:
4

И менять их тоже можно

In [34]:
q[0] = 12
q
Out[34]:
array([12,  5,  8,  9])

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

In [185]:
for x in q:
    print(x)
14
8
14
9

Можно делать срезы (но с ними тоже есть хитрости, об этом ниже).

In [186]:
q[1:3]
Out[186]:
array([ 8, 14])

…но не всегда похожи!

Давайте заведём ещё один массив.

In [187]:
w = array([2, 3, 6, 10])

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

In [188]:
q + w
Out[188]:
array([16, 11, 20, 19])

Если вы хотели сделать конкатенацию, то нужно использовать не оператор сложения, а специальную функцию.

In [189]:
np.concatenate( [q, w] )
Out[189]:
array([14,  8, 14,  9,  2,  3,  6, 10])

Аналогично сложению работают и другие операции. Например, умножение:

In [190]:
q * w
Out[190]:
array([28, 24, 84, 90])

Если у массивов будет разная длина, то ничего не получится:

In [191]:
q = np.array([14, 8, 14, 9])
w = np.array([1, 3, 4])
q + w
---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
<ipython-input-191-a14671572ef0> in <module>()
      1 q = np.array([14, 8, 14, 9])
      2 w = np.array([1, 3, 4])
----> 3 q + w

ValueError: operands could not be broadcast together with shapes (4,) (3,) 

Можно применять различные математические операции к массивам.

In [192]:
x = array([1,2,3,4,5])
y = array([4, 5, 6, 2, 1])
In [193]:
np.exp(x)
Out[193]:
array([   2.71828183,    7.3890561 ,   20.08553692,   54.59815003,
        148.4131591 ])

Заметим, что мы должны были использовать функцию exp из numpy, а не из обычного math. Если бы мы взяли эту функцию из math, ничего бы не сработало.

In [194]:
import math
In [195]:
math.sqrt(x)
---------------------------------------------------------------------------
TypeError                                 Traceback (most recent call last)
<ipython-input-195-655644d1b4c7> in <module>()
----> 1 math.sqrt(x)

TypeError: only length-1 arrays can be converted to Python scalars

Вообще в numpy много математических функций. Вот, например, квадратный корень:

In [72]:
np.sqrt(x)
Out[72]:
array([ 1.        ,  1.41421356,  1.73205081,  2.        ,  2.23606798])

Типы элементов в массивах

Вообще, в массивах могут храниться не только числа.

In [61]:
mixed_array = np.array([1, 2, 3, "Hello"])

Однако, все элементы, лежащие в одном массиве, должны быть одного типа.

In [62]:
mixed_array
Out[62]:
array(['1', '2', '3', 'Hello'], 
      dtype='<U21')

Здесь видно, что числа 1, 2, 3 превратились в строчки '1', '2', '3'. Параметр dtype содержит информацию о типе объектов, хранящихся в массиве. <U21 означает юникодную строку длиной максимум 21 байт. При попытке записать более длинную строку она будет обрезана.

In [63]:
mixed_array[0] = 'Hello, World, This is a Test'
mixed_array[0]
Out[63]:
'Hello, World, This is'

Вообще numpy при создании массива старается не терять информацию и выбирает самый «вместительный» тип.

In [64]:
np.array([1,2,3])
Out[64]:
array([1, 2, 3])
In [65]:
array([1,2,3, 5.])
Out[65]:
array([ 1.,  2.,  3.,  5.])

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

Коварные срезы

Давайте посмотрим внимательно на срезы.

In [78]:
x = array([1.1, 2.2, 3.3, 4.4, 5.5])
In [79]:
s = x[1:3]
In [80]:
s
Out[80]:
array([ 2.2,  3.3])

Пока всё идёт как обычно: мы создали срез, начинающийся с элемента с индексом 1 (то есть второй элемент, нумерация с нуля) и заканчивающийся элементом с индексом 3 (последний элемент всегда не включается).

Теперь попробуем изменить значение элемента в срезе:

In [83]:
s[0] = 100
In [84]:
s
Out[84]:
array([ 100. ,    3.3])

Как вы думаете, что произойдёт с исходным массивом x?

In [85]:
x
Out[85]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])

Он тоже изменился! Раньше мы видели подобную штуку в ситуациях, когда один список имел несколько имён (то есть несколько переменных на него ссылались), но создание среза раньше приводило к копированию информации. Оказывается, в numpy создание среза ничего не копирует: срез — это не новый массив, содержащий те же элементы, что старый, а так называемый view (вид), то есть своего рода интерфейс к старому массиву. Грубо говоря, наш срез s просто помнит, что «его» элемент с индексом 0 — это на самом деле элемент с индексом 1 от исходного массива x, а его элемент с индексом 1 — это на самом деле элемент с индексом 2 от исходного массива, а других элементов у него нет. Можно думать про срез как про такие специальные очки, через которые мы смотрим на исходный массив.

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

Если вам всё-таки нужно сделать копию массива, нужно использовать метод copy().

In [86]:
y = x.copy()
In [87]:
y
Out[87]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [88]:
x
Out[88]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [89]:
y[0] = 12
In [90]:
x
Out[90]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [91]:
y
Out[91]:
array([  12. ,  100. ,    3.3,    4.4,    5.5])
In [92]:
x
Out[92]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])

Продвинутая индексация

Помимо коварных срезов есть ещё некоторые возможности создания новых массивов из старых. Например, можно выбрать из массива элементы с нужными номерами вот так:

In [93]:
x
Out[93]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [94]:
y = x[ [1, 3, 4] ]
In [95]:
y
Out[95]:
array([ 100. ,    4.4,    5.5])

Можно даже использовать одинаковые номера.

In [96]:
y = x[ [1, 1, 1] ]
In [97]:
y
Out[97]:
array([ 100.,  100.,  100.])

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

In [98]:
y[0] = 123
In [99]:
y
Out[99]:
array([ 123.,  100.,  100.])
In [100]:
x
Out[100]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [101]:
x
Out[101]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])

Есть ещё один хитрый способ выбора элементов из массива. Допустим, мы хотим выбрать только те элементы, которые обладают каким-то свойством — скажем, меньше 50. Можно было бы использовать цикл с условием или аналогичный ему list comprehension, но в numpy используют другой синтаксис.

In [102]:
y = x[ x < 50 ]
In [103]:
x
Out[103]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [104]:
y
Out[104]:
array([ 1.1,  3.3,  4.4,  5.5])

Как он работает? Очень просто. (Ну ок, не очень.) Для начала, что такое x < 50? Это результат применения операции «сравнение с 50» к каждому элементу массива. То есть это новый массив.

In [105]:
x < 50
Out[105]:
array([ True, False,  True,  True,  True], dtype=bool)

Если в каком-то месте стоит True, то это означает, что на соответствующем месте в x стоит элемент, который удовлетворяет условию, а если False, то не удовлетворяет.

Теперь можно попробовать подставить массив из True и False в качестве индекса в x.

In [106]:
x[ array([True, True, False, False, True]) ]
Out[106]:
array([   1.1,  100. ,    5.5])

Эта штука выбирает ровно те элементы, на чьих местах стоит True — то есть ровно те, для которых выполнялось условие. То, что нам нужно!

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

In [115]:
x[ (x < 50) & (x > 2) ]
Out[115]:
array([ 3.3,  4.4,  5.5])

Скобочки очень важны, иначе ничего не заработает. Операция & соответствует логическому И и опять же выполняется поэлементно.

In [76]:
(x < 50) & (x > 2)
Out[76]:
array([False, False,  True,  True,  True], dtype=bool)

Для логического ИЛИ мы бы исползовали |, а для отрицания ~.

In [116]:
(x < 50) | (x > 2)
Out[116]:
array([ True,  True,  True,  True,  True], dtype=bool)
In [117]:
~ (x < 50)
Out[117]:
array([False,  True, False, False, False], dtype=bool)

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

In [118]:
x
Out[118]:
array([   1.1,  100. ,    3.3,    4.4,    5.5])
In [121]:
x[ x>50 ] = 0
# заменить все элементы, большие 50, на 0
In [122]:
x
Out[122]:
array([ 1.1,  0. ,  3.3,  4.4,  5.5])

Кстати, чтобы узнать, правда ли, что два массива равны (в том числе, что состоят из одних и тех же элементов, находящихся в одном и том же порядке), теперь нельзя использовать == — ведь это тоже поэлементная операция!

In [142]:
np.array([1, 2, 3]) == np.array([1, 2, 3])
Out[142]:
array([ True,  True,  True], dtype=bool)

Чтобы понять, правда ли, что массивы равны, можно использовать такой синтаксис:

In [144]:
(np.array([1, 2, 3]) == np.array([1,2,3])).all()
Out[144]:
True

Здесь мы сначала сравниваем массивы поэлементно, а потом применяем к результату метод all(), возвращающий истину только если все элементы являются истиными. Этот подход часто используется, хотя имеет свои подводные камни (см. на stackoverflow). Есть и специализированная функция для проверки на равенство:

In [151]:
np.array_equal(np.array([1, 2, 3]), np.array([1, 2, 3]))
Out[151]:
True

Построение графиков в matplotlib

В Python существует много способов строить графики. Мы сейчас рассмотрим самый простой из них, а позже поговорим про более сложные. Для этого нам потребуется библиотека matplotlib, а точнее её часть под названием pyplot. Стандартный способ её импорта выглядит вот так:

In [123]:
import matplotlib.pyplot as plt

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

In [124]:
%matplotlib inline

Простейшее рисование — это функция plot, она принимает на вход список $x$-координат, список $y$-координат и рисует соответствующую картинку либо в виде ломаной:

In [84]:
plt.plot([1, 2, 3, 4], [1, 4, 9, 16])
Out[84]:
[<matplotlib.lines.Line2D at 0x10ff2ef60>]

либо в виде отдельный точек:

In [126]:
plt.plot([1, 2, 3, 4], [1, 4, 9, 16], 'o')
Out[126]:
[<matplotlib.lines.Line2D at 0x1112d2550>]

Либо ещё кучей способов.

In [127]:
plt.plot([1, 2, 3, 4], [1, 4, 9, 16], '-o')
Out[127]:
[<matplotlib.lines.Line2D at 0x111394470>]

Посмотрим, как numpy работает в связке с matplotlib.pyplot. Вообще это всё очень похое на MATLAB, и если вы знаете MATLAB, то для вас многое здесь будет знакомо — и наоборот, после numpy и matplotlib.pyplot будете чувствовать себя как дома в MATLAB.

Chewie, we're home. [Здесь должна была быть картинка из «Звёздных войн», но я не могу её включить по соображениям авторских прав.]

In [128]:
x = np.linspace(-5, 5, 200)
# это массив из 200 элементов, состоящий из равномерно разбросанных чисел от -5 до 5
In [129]:
len(x)
Out[129]:
200
In [132]:
x[:10]
Out[132]:
array([-5.        , -4.94974874, -4.89949749, -4.84924623, -4.79899497,
       -4.74874372, -4.69849246, -4.64824121, -4.59798995, -4.54773869])

Вот так можно нарисовать параболу:

In [136]:
plt.plot(x, x**2)
Out[136]:
[<matplotlib.lines.Line2D at 0x1115d62b0>]

Действительно, x**2 — это массив, элементами которого являются квадраты чисел, лежащих в x. Значит, построив график, состоящий из точек, $x$-координаты которых записаны в x, а $y$-координаты с x**2, мы построим график функции $y=x^2$.

А вот, например, синусоида:

In [139]:
plt.plot(x, np.sin(x))
Out[139]:
[<matplotlib.lines.Line2D at 0x111758710>]

А вот что-то посложнее:

In [140]:
plt.plot(x, np.sin(x**2))
Out[140]:
[<matplotlib.lines.Line2D at 0x11180f7b8>]

Вот так можно построить несколько графиков и сделать подписи.

In [157]:
x = np.linspace(-1,1,201)
plt.plot(x,x**2, label = '$y = x^2$')
plt.plot(x,x**3, label = '$y = x^3$')
plt.legend(loc='best')
Out[157]:
<matplotlib.legend.Legend at 0x111b369b0>

Знак $ в label используется для того, чтобы записывать формулы — это делается в LaTeX-нотации и долларами там обозначается начало и конец формулы. (Кстати, в IPython Notebook в ячейках типа Markdown тоже можно записывать формулы в LaTeX-нотации.)

Конечно, мы могли бы получить x и y не в результате вычисления значений какой-то функции, а откуда-то извне. Возьмём для примера случайные числа.

In [152]:
x = np.random.random(100)
In [153]:
y = np.random.random(100)
plt.plot(x,y, 'o')
Out[153]:
[<matplotlib.lines.Line2D at 0x1118ee668>]

Есть и специализированная функция для создания scatter plot.

In [154]:
plt.scatter(x,y)
Out[154]:
<matplotlib.collections.PathCollection at 0x1119a8048>

Ещё можно построить гистограмму.

In [155]:
plt.hist(x)
Out[155]:
(array([ 10.,   9.,   8.,   8.,   7.,  11.,   9.,  12.,  12.,  14.]),
 array([ 0.00350726,  0.10110942,  0.19871157,  0.29631372,  0.39391588,
         0.49151803,  0.58912018,  0.68672234,  0.78432449,  0.88192664,
         0.9795288 ]),
 <a list of 10 Patch objects>)

Можно строить трёхмерные картинки, но тут уже нужна магия и я не буду вдаваться в детали.

In [175]:
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D
fig = plt.figure()
<matplotlib.figure.Figure at 0x111b6d668>
In [176]:
%matplotlib inline
In [178]:
x,y = np.mgrid[-1:1:0.01, -1:1:0.01]
ax = fig.add_subplot(111, projection='3d')
ax.plot_surface(x,y,x**2+y**2)
fig
Out[178]:

Наконец, можно строить интерактивные картинки!

In [180]:
from ipywidgets import interact, interactive, fixed
import ipywidgets as widgets
import numpy as np
import matplotlib.pyplot as plt
In [181]:
%matplotlib inline
In [182]:
def plot_pic(a, b):
    x = np.linspace(-3,3,200)
    plt.plot(x, np.sin(x*a+b))
In [183]:
interact(plot_pic, a=[0, 3, 0.1], b=[0, 3, 0.1])
Out[183]:
<function __main__.plot_pic>

Функция interact создаёт несколько бегунков и позволяет с их помощью задавать параметры у функции (в данном случае plot_pic), которая строит график.

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

Ещё interact можно вызывать так:

In [184]:
@interact(a=[0, 3, 0.1], b=[0, 3, 0.1])
def plot_pic(a, b):
    x = np.linspace(-3,3,200)
    plt.plot(x, np.sin(x*a+b))

На сегодня всё!