NumPy и pandas

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

NumPy

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

Решением является NumPy. Идея заключается в том, чтобы хранить числа так, как их хранит C, то есть подряд, одного типа, без обёрток, а операции над ними передавать скомпилированному коду, работающему сразу над всем массивом. Выигрыш достигает сотен раз. На NumPy построен научный стек: SciPy, pandas, scikit-learn и библиотеки машинного обучения используют массивы NumPy.

Полная документация: numpy.org/doc.

NumPy решает две задачи:

  • хранить многомерные массивы (в том числе матрицы);
  • быстро считать математические функции сразу от всего массива.

Основой библиотеки является один объект, ndarray.

Отличия массива от списка:

  • длина массива, заданная в момент создания, остаётся неизменной, тогда как список растёт динамически;
  • все элементы массива одного типа;
  • операции пишутся сразу над массивом целиком, без цикла.

Отсюда следуют две сильные стороны NumPy: векторизация и broadcasting.

import numpy as np

Способы создания массивов

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

Конвертация из структур Python

np.array([1, 2, 3, 4, 5])
array([1, 2, 3, 4, 5])

При конвертации можно задавать тип данных с помощью аргумента dtype:

np.array([1, 2, 3, 4, 5], dtype=np.float32)
array([1., 2., 3., 4., 5.], dtype=float32)

Аналогичное преобразование:

np.float32([1, 2, 3, 4, 5])
array([1., 2., 3., 4., 5.], dtype=float32)

Генерация массивов

  • arange работает как аналог range из Python, но принимает и нецелочисленный шаг
  • linspace равномерно разбивает отрезок на n-1 интервал
  • logspace разбивает отрезок по логарифмической шкале
  • zeros создаёт массив заданной размерности, заполненный нулями
  • ones создаёт массив заданной размерности, заполненный единицами
  • empty создаёт массив заданной размерности, не инициализированный никаким значением, то есть заполненный мусором из памяти
np.arange(0, 5, 0.5)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5])
np.linspace(0, 5, 11)
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. ])
np.logspace(0, 9, 10, base=2)
array([  1.,   2.,   4.,   8.,  16.,  32.,  64., 128., 256., 512.])
np.zeros((2, 2))
array([[0., 0.],
       [0., 0.]])
np.ones((2, 2))
array([[1., 1.],
       [1., 1.]])
np.empty((2, 2))
array([[1., 1.],
       [1., 1.]])
np.diag([1,2,3])
array([[1, 0, 0],
       [0, 2, 0],
       [0, 0, 3]])

Размеры массива хранятся в поле shape, а число измерений — в поле ndim.

array = np.ones((2, 3,))
print('Размерность массива - %s, количество размерностей - %d'%(array.shape, array.ndim))
array
Размерность массива - (2, 3), количество размерностей - 2





array([[1., 1., 1.],
       [1., 1., 1.]])
## Чему равны ndim и shape в следующих случаях
print(np.diag([1,2,3]).shape, np.diag([1,2,3]).ndim)
print(np.zeros((5, 5, 5)).shape, np.zeros((5, 5, 5)).ndim)
(3, 3) 2
(5, 5, 5) 3

Метод reshape изменяет форму массива, не изменяя самих данных

array = np.arange(0, 6, 0.5)
array = array.reshape((2, 6))
array
array([[0. , 0.5, 1. , 1.5, 2. , 2.5],
       [3. , 3.5, 4. , 4.5, 5. , 5.5]])

Многомерный массив разворачивается в вектор функцией ravel

array = np.ravel(array)
array
array([0. , 0.5, 1. , 1.5, 2. , 2.5, 3. , 3.5, 4. , 4.5, 5. , 5.5])
# Какие будут массивы?
print(np.ravel(np.diag([1,2])))
print(np.reshape(np.diag([1,2]), [1, 4]))
[1 0 0 2]
[[1 0 0 2]]

Индексация

В NumPy применяется привычная индексация Python, включая отрицательные индексы и срезы, записываемые так же, как для списка

print(array[0])
print(array[-1])
print(array[1:-1])
print(array[1:-1:2])
print(array[::-1])
0.0
5.5
[0.5 1.  1.5 2.  2.5 3.  3.5 4.  4.5 5. ]
[0.5 1.5 2.5 3.5 4.5]
[5.5 5.  4.5 4.  3.5 3.  2.5 2.  1.5 1.  0.5 0. ]
print(array.shape)
(12,)
print(array[None,0:, None].ndim, array[None,0:, None].shape)
array[None,0:, None]
3 (1, 12, 1)





array([[[0. ],
        [0.5],
        [1. ],
        [1.5],
        [2. ],
        [2.5],
        [3. ],
        [3.5],
        [4. ],
        [4.5],
        [5. ],
        [5.5]]])

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

т.е. вместо matrix[i][j] нужно использовать matrix[i, j]

Массив, подставленный вместо индекса, может быть и списком номеров, и булевой маской:

array[[0, 2, 4, 6, 8, 10]]
array([0., 1., 2., 3., 4., 5.])
array[[True, False, True, False, True, False, True, False, True, False, True, False]]
array([0., 1., 2., 3., 4., 5.])
# Что будет выведено?
x = np.array([[1, 2, 3]])
y = np.array([1, 2, 3])

print (x.shape, y.shape)

print(np.array_equal(x, y))
print(np.array_equal(x, y[None, :]))
(1, 3) (3,)
False
True
x = np.arange(10)
x
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])
x[(x % 2 == 0) & (x > 5)]
array([6, 8])
print(x)
y = x[x>5] 
y *= 2
print(y)
print(x)
[0 1 2 3 4 5 6 7 8 9]
[12 14 16 18]
[0 1 2 3 4 5 6 7 8 9]

Срез массива в NumPy является представлением тех же данных, а не их копией: изменение среза наподобие x[2:5] приводит к изменению исходного массива. Отбор по маске или по списку индексов всегда возвращает копию, и исходный массив остаётся нетронутым. Когда требуются собственные данные, применяется метод copy.

x.copy()
array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])

Сохранение и чтение массивов в бинарном формате

with open('out.npy', 'wb') as f:
    np.save(f, x)
    
with open('out.npy', 'rb') as f:
    print(f.read())
    
with open('out.npy', 'rb') as f:
    y = np.load(f)
    print(y)    
b"\x93NUMPY\x01\x00v\x00{'descr': '<i8', 'fortran_order': False, 'shape': (10,), }                                                           \n\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x05\x00\x00\x00\x00\x00\x00\x00\x06\x00\x00\x00\x00\x00\x00\x00\x07\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\t\x00\x00\x00\x00\x00\x00\x00"
[0 1 2 3 4 5 6 7 8 9]

Чтение данных с помощью функции genfromtxt

Для примера потребуется файл iris_subset.txt. Он не приложен, и настоящий не нужен: числа в нём случайные (чашелистик длиной 1134 см встретится ниже в выводе). Важна только структура: текстовая таблица с заголовком, четыре числовых столбца и один строковый. Такой файл необходимо создать самостоятельно, поместив в первую строку имена столбцов, разделённые , :

sepal_length_in_cm, sepal_width_in_cm, petal_length_in_cm, petal_width_in_cm, class
1.0, 1.0, 10.0, 121.0, setosa
1.0, 314.0, 13.0, 121.0, versicolor

Если требуется работать с настоящими ирисами, их предоставляет scikit-learn одной строкой: from sklearn.datasets import load_iris.

iris = np.genfromtxt('iris_subset.txt', delimiter=', ', names=True, dtype=[('sepal_length_in_cm', 'f8'), 
                                                                          ('sepal_width_in_cm', 'f8'), 
                                                                          ('petal_length_in_cm', 'f8'), 
                                                                          ('petal_width_in_cm', 'f8'),
                                                                          ('class', 'U10')])
iris
array([(1.000e+00,   1. ,   10.,   121. , 'setosa'),
       (1.000e+00, 314. ,   13.,   121. , 'versicolor'),
       (1.134e+03,   1. ,  103.,  1421. , 'setosa'),
       (1.000e+00, 141. ,   10.,   121. , 'versicolor'),
       (1.440e+02,   1. , 4582., 13481. , 'versicolor'),
       (1.000e+00,  13.3,   10.,   121. , 'versicolor'),
       (1.141e+03,   1. , 1341.,  1231.1, 'setosa'),
       (7.320e+02, 131. ,  139.,    92.1, 'setosa')],
      dtype=[('sepal_length_in_cm', '<f8'), ('sepal_width_in_cm', '<f8'), ('petal_length_in_cm', '<f8'), ('petal_width_in_cm', '<f8'), ('class', '<U10')])

genfromtxt с names=True возвращает структурированный массив, хранящий имена столбцов вместе с данными. Строка из него запрашивается по номеру, а столбец — по названию.

print('Описание первого элемента: %s'%iris[0])
print('Значения столбца sepal_length_in_cm: %s'%iris['sepal_length_in_cm'])
Описание первого элемента: (1., 1., 10., 121., 'setosa')
Значения столбца sepal_length_in_cm: [1.000e+00 1.000e+00 1.134e+03 1.000e+00 1.440e+02 1.000e+00 1.141e+03
 7.320e+02]
sepal_length_setosa = iris['sepal_length_in_cm'][iris['class'] == 'setosa']
sepal_length_versicolor = iris['sepal_length_in_cm'][iris['class'] == 'versicolor']

print('Значения столбца sepal_length_in_cm\n\tclass setosa: %s\n\tclass versicolor: %s'%(sepal_length_setosa, 
                                                                                         sepal_length_versicolor))
Значения столбца sepal_length_in_cm
	class setosa: [1.000e+00 1.134e+03 1.141e+03 7.320e+02]
	class versicolor: [  1.   1. 144.   1.]

Строки в начале и в конце файла пропускаются аргументами skip_header и skip_footer, а столбцы отбираются через usecols.

iris_class = np.genfromtxt('iris_subset.txt', delimiter=', ', skip_header=1, usecols=4, dtype='U10')
iris_class
array(['setosa', 'versicolor', 'setosa', 'versicolor', 'versicolor',
       'versicolor', 'setosa', 'setosa'], dtype='<U10')
iris_features = np.genfromtxt('iris_subset.txt', delimiter=', ', skip_header=1, usecols=range(4))
iris_features
array([[1.0000e+00, 1.0000e+00, 1.0000e+01, 1.2100e+02],
       [1.0000e+00, 3.1400e+02, 1.3000e+01, 1.2100e+02],
       [1.1340e+03, 1.0000e+00, 1.0300e+02, 1.4210e+03],
       [1.0000e+00, 1.4100e+02, 1.0000e+01, 1.2100e+02],
       [1.4400e+02, 1.0000e+00, 4.5820e+03, 1.3481e+04],
       [1.0000e+00, 1.3300e+01, 1.0000e+01, 1.2100e+02],
       [1.1410e+03, 1.0000e+00, 1.3410e+03, 1.2311e+03],
       [7.3200e+02, 1.3100e+02, 1.3900e+02, 9.2100e+01]])
features_setosa = iris_features[iris_class == 'setosa']
features_versicolor = iris_features[iris_class == 'versicolor']

Операции в NumPy производятся над векторами одинаковой размерности целиком, без цикла.

Поэлементная разность двух векторов:

sepal_length_versicolor - sepal_length_setosa
array([    0., -1133.,  -997.,  -731.])

Аналогично для многомерных массивов.

features_versicolor - features_setosa
array([[ 0.00000e+00,  3.13000e+02,  3.00000e+00,  0.00000e+00],
       [-1.13300e+03,  1.40000e+02, -9.30000e+01, -1.30000e+03],
       [-9.97000e+02,  0.00000e+00,  3.24100e+03,  1.22499e+04],
       [-7.31000e+02, -1.17700e+02, -1.29000e+02,  2.89000e+01]])

Broadcasting

Broadcasting снимает требование одинаковой формы и разрешает арифметику над массивами разных, но согласованных между собой размерностей. Простейшим случаем является умножение вектора на число.

Imgur

2*np.arange(1, 4)
array([2, 4, 6])

Правило согласования размерностей:

In order to broadcast, the size of the trailing axes for both arrays in an operation must either be the same size or one of them must be one.

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

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

a = np.ones((2, 3, 4))
b = np.ones(4)
c = a * b  # здесь a.shape = (2, 3, 4), а b.shape считается равным (1, 1, 4)

Прибавим к каждой строке матрицы один и тот же вектор:

Imgur

np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]]) + np.arange(3)
array([[ 0,  1,  2],
       [10, 11, 12],
       [20, 21, 22],
       [30, 31, 32]])

Со столбцами такой приём не работает: вектор из четырёх элементов не согласуется с матрицей по последней оси.

Imgurl

Сначала вектор, дополненный новой осью, приводится к виду:

np.arange(4)[:, np.newaxis]
array([[0],
       [1],
       [2],
       [3]])

Затем к нему прибавляется матрица:

np.arange(4)[:, np.newaxis]+np.array([[0, 0, 0], [10, 10, 10], [20, 20, 20], [30, 30, 30]])
array([[ 0,  0,  0],
       [11, 11, 11],
       [22, 22, 22],
       [33, 33, 33]])

Кроме того, в NumPy имеются сводные операции над массивами: np.min, np.max, np.sum, np.mean и т.д.

print('Среднее значение всех значений класса versicolor: %s'%np.mean(features_versicolor))
print('Среднее значение каждого признака класса versicolor: %s'%np.mean(features_versicolor, axis=0))
Среднее значение всех значений класса versicolor: 1192.20625
Среднее значение каждого признака класса versicolor: [  36.75   117.325 1153.75  3461.   ]

Вычислим без цикла \(\frac{1}{n} \sum\limits_{i=1}^n |x_i-y_i|\) для каждой пары \((x, y)\), где \(x\) обозначает вектор признаков объекта из класса setosa, а \(y\) вектор признаков объекта из класса versicolor.

np.mean(np.abs(features_setosa - features_versicolor[:, np.newaxis]), axis=2)
array([[7.900000e+01, 7.090000e+02, 9.727750e+02, 2.672250e+02],
       [3.500000e+01, 6.665000e+02, 9.302750e+02, 2.247250e+02],
       [4.518750e+03, 4.382250e+03, 4.121975e+03, 4.637475e+03],
       [3.075000e+00, 6.345750e+02, 8.983500e+02, 2.516500e+02]])

Операции

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

x = np.arange(40).reshape(5, 2, 4)
print(x)
[[[ 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]]]
print(x.mean())
print(np.mean(x))
19.5
19.5
x.mean(axis=0)
array([[16., 17., 18., 19.],
       [20., 21., 22., 23.]])
x.mean(axis=1)
array([[ 2.,  3.,  4.,  5.],
       [10., 11., 12., 13.],
       [18., 19., 20., 21.],
       [26., 27., 28., 29.],
       [34., 35., 36., 37.]])
x.mean(axis=2)
array([[ 1.5,  5.5],
       [ 9.5, 13.5],
       [17.5, 21.5],
       [25.5, 29.5],
       [33.5, 37.5]])
x.mean(axis=(0,2))
array([17.5, 21.5])
x.mean(axis=(0,1,2))
19.5

Конкатенация многомерных массивов

Объединение массивов выполняют функции np.concatenate, np.hstack, np.vstack, np.dstack, различающиеся только осью объединения.

x = np.arange(10).reshape(5, 2)
y = np.arange(100, 120).reshape(5, 4)
x
array([[0, 1],
       [2, 3],
       [4, 5],
       [6, 7],
       [8, 9]])
y
array([[100, 101, 102, 103],
       [104, 105, 106, 107],
       [108, 109, 110, 111],
       [112, 113, 114, 115],
       [116, 117, 118, 119]])
np.hstack((x, y))
array([[  0,   1, 100, 101, 102, 103],
       [  2,   3, 104, 105, 106, 107],
       [  4,   5, 108, 109, 110, 111],
       [  6,   7, 112, 113, 114, 115],
       [  8,   9, 116, 117, 118, 119]])
x = np.ones([2, 3])
y = np.zeros([2, 2])
# Какой будет результат
print(np.hstack((x,y)).shape)
print(np.vstack((x,y)).shape)
(2, 5)



---------------------------------------------------------------------------

ValueError                                Traceback (most recent call last)

Cell In[54], line 3
      1 # Какой будет результат
      2 print(np.hstack((x,y)).shape)
----> 3 print(np.vstack((x,y)).shape)


File /opt/anaconda3/lib/python3.12/site-packages/numpy/core/shape_base.py:289, in vstack(tup, dtype, casting)
    287 if not isinstance(arrs, list):
    288     arrs = [arrs]
--> 289 return _nx.concatenate(arrs, 0, dtype=dtype, casting=casting)


ValueError: all the input array dimensions except for the concatenation axis must match exactly, but along dimension 1, the array at index 0 has size 3 and the array at index 1 has size 2
p = np.arange(1).reshape([1, 1, 1, 1])
p
array([[[[0]]]])
print("vstack: ", np.vstack((p, p)).shape)
print("hstack: ", np.hstack((p, p)).shape)
print("dstack: ", np.dstack((p, p)).shape)
print("concatenate: ", np.concatenate((p, p), axis=3).shape)
vstack:  (2, 1, 1, 1)
hstack:  (1, 2, 1, 1)
dstack:  (1, 1, 2, 1)
concatenate:  (1, 1, 1, 2)

Типы

От типа массива зависят и занимаемая память, и диапазон представимых значений. Рассмотрим, что происходит с числом 70000 в uint16. С версии NumPy 2.0 конструктор массива на таком значении завершается ошибкой OverflowError, а без предупреждения оно усекается только при явном приведении через astype, превращаясь в 4464; такое переполнение, не сопровождаемое ошибкой, является классическим источником неверных результатов.

x = [1, 2, 70000]
np.array(x, dtype=np.float32)
array([1.e+00, 2.e+00, 7.e+04], dtype=float32)
np.array(x, dtype=np.uint16)
OverflowError: Python integer 70000 out of bounds for uint16

Ранее NumPy усекал значение, не помещавшееся в тип, и лишь выдавал предупреждение; с версии 2.0 это ошибка. Незаметное переполнение осталось только при явном приведении:

np.array(70000).astype(np.uint16)
4464

uint16 хранит остаток по модулю \( 2^{16} \), а \( 70000 - 65536 = 4464 \). Такая ошибка не приводит к исключению и ничего не выводит.

np.array(x, dtype=np.str_)
array(['1', '2', '70000'], dtype='<U5')

Функциональное программирование

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

def f(value):
    return np.sqrt(value)
print(np.apply_along_axis(f, 0, np.arange(10)))
[0.         1.         1.41421356 1.73205081 2.         2.23606798
 2.44948974 2.64575131 2.82842712 3.        ]
vf = np.vectorize(f)
%%timeit 
vf(np.arange(100000))
146 ms ± 2.4 ms per loop (mean ± std. dev. of 7 runs, 10 loops each)
%%timeit 
np.apply_along_axis(f, 0, np.arange(100000)) 
1.89 ms ± 31.9 μs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
%%timeit 
np.array([f(v) for v in np.arange(100000)])
129 ms ± 861 μs per loop (mean ± std. dev. of 7 runs, 10 loops each)

Разница в семьдесят раз выглядит убедительно, однако выводы из неё были бы поспешными. np.vectorize и списковое включение вызывают f сто тысяч раз, и 130–150 миллисекунд составляют стоимость ста тысяч вызовов функции Python. В то же время apply_along_axis с axis=0 на одномерном массиве вызывает f один раз, передавая в неё весь массив целиком, так что измерен здесь один векторный np.sqrt, а не поэлементный обход.

Прежде чем доверять отношению времён, необходимо разобраться, что делает каждая из сравниваемых версий. np.vectorize не векторизует, а лишь оборачивает цикл, и ускорения от него ожидать не следует.

Pandas

pandas.pydata.org/docs/

Pandas читает данные, приводит их в порядок, вычисляет по ним сводки и строит графики. Если NumPy предоставляет массив чисел, то pandas предоставляет таблицу с именованными столбцами и индексом, размечающим строки.

import pandas as pd
df = pd.read_csv("titanic.csv", sep='\t')

Набор данных о «Титанике» находится в открытом доступе (например, в OpenML под именем titanic). Файл использует табуляцию, а не запятую в качестве разделителя, поэтому sep='\t' обязателен. Файл, загруженный из другого источника, может оказаться обычным CSV.

df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
0 1 0 3 Braund, Mr. Owen Harris male 22.0 1 0 A/5 21171 7.2500 NaN S
1 2 1 1 Cumings, Mrs. John Bradley (Florence Briggs Th... female 38.0 1 0 PC 17599 71.2833 C85 C
2 3 1 3 Heikkinen, Miss. Laina female 26.0 0 0 STON/O2. 3101282 7.9250 NaN S
view = df[df['Sex'] == 'female']
list(((df['Sex'] == 'female') & (df['Age'] > 30)).index)
[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,
 48,
 49,
 50,
 51,
 52,
 53,
 54,
 55,
 56,
 57,
 58,
 59,
 60,
 61,
 62,
 63,
 64,
 65,
 66,
 67,
 68,
 69,
 70,
 71,
 72,
 73,
 74,
 75,
 76,
 77,
 78,
 79,
 80,
 81,
 82,
 83,
 84,
 85,
 86,
 87,
 88,
 89,
 90,
 91,
 92,
 93,
 94,
 95,
 96,
 97,
 98,
 99,
 100,
 101,
 102,
 103,
 104,
 105,
 106,
 107,
 108,
 109,
 110,
 111,
 112,
 113,
 114,
 115,
 116,
 117,
 118,
 119,
 120,
 121,
 122,
 123,
 124,
 125,
 126,
 127,
 128,
 129,
 130,
 131,
 132,
 133,
 134,
 135,
 136,
 137,
 138,
 139,
 140,
 141,
 142,
 143,
 144,
 145,
 146,
 147,
 148,
 149,
 150,
 151,
 152,
 153,
 154,
 155]
df[(df['Sex'] == 'female') & (df['Age'] > 30)].index
Index([1, 3, 11, 15, 18, 25, 40, 52, 61, 85, 98, 123, 132], dtype='int64')
df.drop(index=df[(df['Sex'] == 'female') & (df['Age'] > 30)].index, inplace=True)
df.loc[78]
PassengerId                               79
Survived                                   1
Pclass                                     2
Name           Caldwell, Master. Alden Gates
Sex                                     male
Age                                     0.83
SibSp                                      0
Parch                                      2
Ticket                                248738
Fare                                    29.0
Cabin                                    NaN
Embarked                                   S
Name: 78, dtype: object
df.iloc[0]
PassengerId                          1
Survived                             0
Pclass                               3
Name           Braund, Mr. Owen Harris
Sex                               male
Age                               22.0
SibSp                                1
Parch                                0
Ticket                       A/5 21171
Fare                              7.25
Cabin                              NaN
Embarked                             S
Name: 0, dtype: object
df.describe()
PassengerId Survived Pclass Age SibSp Parch Fare
count 143.000000 143.000000 143.000000 113.000000 143.000000 143.000000 143.000000
mean 80.902098 0.307692 2.461538 26.702035 0.601399 0.391608 27.526018
std 44.536473 0.463161 0.776134 14.483237 1.075593 0.813919 40.406013
min 1.000000 0.000000 1.000000 0.830000 0.000000 0.000000 6.750000
25% 43.500000 0.000000 2.000000 19.000000 0.000000 0.000000 7.925000
50% 81.000000 0.000000 3.000000 24.000000 0.000000 0.000000 13.000000
75% 118.500000 1.000000 3.000000 33.000000 1.000000 0.000000 29.597900
max 156.000000 1.000000 3.000000 71.000000 5.000000 5.000000 263.000000
df[["Sex", "Cabin"]].describe()
Sex Cabin
count 143 25
unique 2 23
top male C23 C25 C27
freq 100 2

Срезы в DataFrame

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

Индексация

После сортировки индекс, приписанный строке, сохраняется и перестаёт совпадать с её номером по порядку.

df.sort_values("Age", inplace=True)
df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S
119 120 0 3 Andersson, Miss. Ellis Anna Maria female 2.00 4 2 347082 31.275 NaN S
df.iloc[78]
PassengerId                              67
Survived                                  1
Pclass                                    2
Name           Nye, Mrs. (Elizabeth Ramell)
Sex                                  female
Age                                    29.0
SibSp                                     0
Parch                                     0
Ticket                           C.A. 29395
Fare                                   10.5
Cabin                                   F33
Embarked                                  S
Name: 66, dtype: object
df.loc[78]
PassengerId                               79
Survived                                   1
Pclass                                     2
Name           Caldwell, Master. Alden Gates
Sex                                     male
Age                                     0.83
SibSp                                      0
Parch                                      2
Ticket                                248738
Fare                                    29.0
Cabin                                    NaN
Embarked                                   S
Name: 78, dtype: object
df.loc[[78, 79, 100], ["Age", "Cabin"]]
Age Cabin
78 0.83 NaN
79 30.00 NaN
100 28.00 NaN

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

df_slice_copy = df.loc[[78, 79, 100], ["Age", "Cabin"]].copy()
df_slice_copy[:] = 3
df_slice_copy
Age Cabin
78 3.0 3
79 3.0 3
100 3.0 3

Если же изменять требуется саму основную таблицу, то используется loc.

df.head(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S
119 120 0 3 Andersson, Miss. Ellis Anna Maria female 2.00 4 2 347082 31.275 NaN S
some_slice = df["Age"].isin([20, 25,30])
df.loc[some_slice, "Fare"] = df.loc[some_slice, "Fare"] * 10

Следующий способ применять не рекомендуется:

slice_df = df[some_slice]
slice_df["Fare"] = slice_df["Fare"] * 10

С pandas 3.0 включён механизм Copy-on-Write, и SettingWithCopyWarning удалён. Запись в slice_df теперь гарантированно попадает в копию, исходная таблица не изменяется, и предупреждения об этом не выдаётся. Правило прежнее: исходная таблица изменяется только через df.loc[маска, столбец] = ....

Столбцы отбираются названием или списком названий в [].

Замечание: если передаётся название одного столбца, то возвращается объект класса pandas.Series, а если список названий столбцов, то возвращается pandas.DataFrame; для получения numpy.array достаточно обратиться к полю values.

Series и DataFrame имеют много общих методов, работающих одинаково в обоих случаях.

df["Age"].head(5)
78     0.83
7      2.00
119    2.00
16     2.00
43     3.00
Name: Age, dtype: float64
df[["Age"]].head(5)
Age
78 0.83
7 2.00
119 2.00
16 2.00
43 3.00

pd.Series

Одномерный срез датафрейма является pd.Series, столбцом значений, снабжённым индексом.

Извлечь из pd.Series обычный np.array можно, однако обычно это не требуется: вместе с массивом теряется индекс.

df["Age"].head(5).values
array([0.83, 2.  , 2.  , 2.  , 3.  ])

Индекс извлекается отдельно:

df["Age"].head(5).index
Index([78, 7, 119, 16, 43], dtype='int64')

Создаётся Series так же, как np.array, только индекс задаётся явно.

pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
Red      1
Green    2
Blue     3
dtype: int64
pd.Series(1, index=["Red", "Green", "Blue"])
Red      1
Green    1
Blue     1
dtype: int64
pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
Red      1
Green    2
Blue     3
dtype: int64

Series разворачивается обратно в DataFrame:

s = pd.Series([1, 2, 3], index=["Red", "Green", "Blue"])
s.to_frame("Values")
Values
Red 1
Green 2
Blue 3
s.loc["Red"]
1
s.iloc[0]
1

Объединение таблиц

Две таблицы сводятся в одну методом join, сопоставляющим строки по индексу.

df1 = df[["Age", "Parch"]].copy()
df2 = df[["Ticket", "Fare"]].copy()
df1.join(df2).head(5)
Age Parch Ticket Fare
78 0.83 2 248738 29.0000
7 2.00 1 349909 21.0750
119 2.00 2 347082 31.2750
16 2.00 1 382652 29.1250
43 3.00 2 SC/Paris 2123 41.5792
df1 = df[["Age", "Parch", "PassengerId"]].copy()
df2 = df[["Ticket", "Fare", "PassengerId"]].copy()
pd.merge(df1, df2, on=["PassengerId"]).head(5)
Age Parch PassengerId Ticket Fare
0 0.83 2 79 248738 29.0000
1 2.00 1 8 349909 21.0750
2 2.00 2 120 347082 31.2750
3 2.00 1 17 382652 29.1250
4 3.00 2 44 SC/Paris 2123 41.5792
pd.merge(df1, df2, on=["PassengerId"], how="inner").head(5)
Age Parch PassengerId Ticket Fare
0 0.83 2 79 248738 29.0000
1 2.00 1 8 349909 21.0750
2 2.00 2 120 347082 31.2750
3 2.00 1 17 382652 29.1250
4 3.00 2 44 SC/Paris 2123 41.5792

Группировка

Средний возраст пассажира по классам каюты может быть вычислен напрямую тремя почти одинаковыми строками. Такой подход не рекомендуется: классов может оказаться тридцать. Для этой задачи предназначен groupby, разбивающий таблицу на группы.

print("Pclass 1: ", df[df["Pclass"] == 1]["Age"].mean())
print("Pclass 2: ", df[df["Pclass"] == 2]["Age"].mean())
print("Pclass 3: ", df[df["Pclass"] == 3]["Age"].mean())
Pclass 1:  36.86363636363637
Pclass 2:  26.68576923076923
Pclass 3:  23.26923076923077
df.groupby(["Pclass"])[["Age"]].mean()
Age
Pclass
1 36.863636
2 26.685769
3 23.269231

Сам по себе groupby ничего не вычисляет, а возвращает объект, хранящий разбиение и ожидающий сводной операции.

df.groupby(["Survived", "Pclass"])
<pandas.core.groupby.generic.DataFrameGroupBy object at 0x73b08903f7d0>
df.groupby(["Survived", "Pclass"])["PassengerId"].count()
Survived  Pclass
0         1         18
          2         16
          3         65
1         1          7
          2         11
          3         26
Name: PassengerId, dtype: int64
df.groupby(["Survived", "Pclass"])[["PassengerId", "Cabin"]].count()
PassengerId Cabin
Survived Pclass
0 1 18 12
2 16 1
3 65 1
1 1 7 7
2 11 2
3 26 2
df.groupby(["Survived", "Pclass"])[["PassengerId", "Fare"]].describe()
PassengerId Fare
count mean std min 25% 50% 75% max count mean std min 25% 50% 75% max
Survived Pclass
0 1 18.0 82.555556 44.501450 7.0 40.75 88.5 117.00 156.0 18.0 80.035183 66.109719 27.7208 51.896875 61.2771 78.721875 263.0000
2 16.0 107.187500 44.842270 21.0 72.50 122.0 145.25 151.0 16.0 33.555725 32.412477 10.5000 12.881250 23.5000 31.740600 130.0000
3 65.0 80.892308 42.930585 1.0 49.00 87.0 114.00 155.0 65.0 19.123272 20.710190 6.7500 7.895800 8.0500 21.075000 98.2500
1 1 7.0 84.000000 49.568135 24.0 44.00 89.0 117.50 152.0 7.0 90.966057 85.998766 26.2833 35.500000 63.3583 106.560400 263.0000
2 11.0 57.181818 35.261362 10.0 33.00 57.0 73.00 134.0 11.0 21.627273 10.581905 10.5000 11.750000 26.0000 28.375000 41.5792
3 26.0 72.807692 46.318048 3.0 34.00 72.0 109.50 147.0 26.0 16.698400 24.254889 7.1417 7.756250 7.9250 14.244775 124.7500

Работа с временными метками

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

tdf = df.copy()
tdf["ts"] = range(1560000000, 1560000000 + tdf.shape[0])
tdf.head(2)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked ts
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S 1560000000
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S 1560000001

Столбец, приведённый к datetime, получает операции, недоступные целому числу.

tdf["ts"] = pd.to_datetime(tdf["ts"], unit="s")
tdf.head(2)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked ts
78 79 1 2 Caldwell, Master. Alden Gates male 0.83 0 2 248738 29.000 NaN S 2019-06-08 13:20:00
7 8 0 3 Palsson, Master. Gosta Leonard male 2.00 3 1 349909 21.075 NaN S 2019-06-08 13:20:01

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

tdf.set_index("ts", inplace=True)
tdf.resample("15s").sum()[["PassengerId", "Survived", "Pclass", "Sex"]]
PassengerId Survived Pclass Sex
ts
2019-06-08 13:20:00 862 7 41 malemalefemalemalefemalemalefemalefemalemalefe...
2019-06-08 13:20:15 1302 4 40 femalefemalefemalefemalemalemalefemalefemalefe...
2019-06-08 13:20:30 1235 3 38 malemalefemalefemalemalemalemalemalemalemalema...
2019-06-08 13:20:45 1628 6 33 femalefemalemalemalemalemalemalemalefemalemale...
2019-06-08 13:21:00 1057 5 37 malemalemalemalefemalefemalemalefemalemalemale...
2019-06-08 13:21:15 1144 6 37 malemalefemalefemalemalefemalemalemalemalemale...
2019-06-08 13:21:30 1590 0 28 malemalemalemalemalemalemalemalemalemalemalema...
2019-06-08 13:21:45 845 4 33 malemalemalemalemalemalemalemalemalemalefemale...
2019-06-08 13:22:00 912 6 41 femalemalemalemalemalefemalemalemalemalemalema...
2019-06-08 13:22:15 994 3 24 malemalefemalemalemalefemalefemalemale

Скользящие окна

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

Imgurl

tdf.sort_index(inplace=True)
tdf[["Fare"]].rolling(window=5).mean().head(10)
Fare
ts
2019-06-08 13:20:00 NaN
2019-06-08 13:20:01 NaN
2019-06-08 13:20:02 NaN
2019-06-08 13:20:03 NaN
2019-06-08 13:20:04 30.41084
2019-06-08 13:20:05 30.19084
2019-06-08 13:20:06 29.31584
2019-06-08 13:20:07 28.61084
2019-06-08 13:20:08 30.72334
2019-06-08 13:20:09 26.62250

Скользящее окно сочетается с группировкой: окно внутри каждой группы вычисляется независимо.

rol = tdf[["Sex", "Fare"]].groupby(["Sex"]).rolling(window=5).mean()
rol.head(100)
Fare
Sex ts
female 2019-06-08 13:20:02 NaN
2019-06-08 13:20:04 NaN
2019-06-08 13:20:06 NaN
2019-06-08 13:20:07 NaN
2019-06-08 13:20:09 27.67584
... ... ...
male 2019-06-08 13:21:26 14.02416
2019-06-08 13:21:27 17.12416
2019-06-08 13:21:28 12.72000
2019-06-08 13:21:29 16.34084
2019-06-08 13:21:30 19.81000

100 rows × 1 columns

rol.loc['male'].head(10)
Fare
ts
2019-06-08 13:20:00 NaN
2019-06-08 13:20:01 NaN
2019-06-08 13:20:03 NaN
2019-06-08 13:20:05 NaN
2019-06-08 13:20:08 29.35750
2019-06-08 13:20:11 32.93750
2019-06-08 13:20:12 30.97084
2019-06-08 13:20:19 26.98918
2019-06-08 13:20:20 28.28418
2019-06-08 13:20:26 22.64668

Работа со строками

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

df["Name"].str.lower()\
          .str.replace(",", " ")\
          .str.split(".").str[1]\
          .head(10)
78                    alden gates
7                   gosta leonard
119              ellis anna maria
16                         eugene
43      simonne marie anne andree
63                         harald
10                 marguerite rut
58               constance mirium
50                     juha niilo
24                 torborg danira
Name: Name, dtype: object

Пропущенные значения

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

df["Cabin"].head(15)
78     NaN
7      NaN
119    NaN
16     NaN
43     NaN
63     NaN
10      G6
58     NaN
50     NaN
24     NaN
147    NaN
59     NaN
125    NaN
39     NaN
9      NaN
Name: Cabin, dtype: object
df["Cabin"].dropna().head(15)
10              G6
136            D47
27     C23 C25 C27
102            D26
151             C2
97         D10 D12
88     C23 C25 C27
118        B58 B60
139            B86
75           F G73
23              A6
66             F33
21             D56
148             F2
137           C123
Name: Cabin, dtype: object
df["Cabin"].fillna(3).head(5)
78     3
7      3
119    3
16     3
43     3
Name: Cabin, dtype: object
df["Cabin"].bfill().head(15)
78      G6
7       G6
119     G6
16      G6
43      G6
63      G6
10      G6
58     D47
50     D47
24     D47
147    D47
59     D47
125    D47
39     D47
9      D47
Name: Cabin, dtype: object
pd.isna(df["Cabin"]).head(10)
78      True
7       True
119     True
16      True
43      True
63      True
10     False
58      True
50      True
24      True
Name: Cabin, dtype: bool

Функция apply

Когда готовой векторной операции не нашлось, остаётся apply, применяющий заданную функцию к каждой строке (axis=1) или к каждому столбцу. Внутри работает обычный цикл Python со всеми издержками, рассмотренными в главе про производительность, поэтому перед написанием apply следует ещё раз поискать векторное решение.

def dummpy_example(row):
    return row['Sex'] * row['Pclass']

df['dummy_example'] = df.apply(dummpy_example, axis=1)
df.tail(3)
PassengerId Survived Pclass Name Sex Age SibSp Parch Ticket Fare Cabin Embarked dummy_example
128 129 1 3 Peter, Miss. Anna female NaN 1 1 2668 22.3583 F E69 C femalefemalefemale
140 141 0 3 Boulos, Mrs. Joseph (Sultana) female NaN 0 2 2678 15.2458 NaN C femalefemalefemale
154 155 0 3 Olsen, Mr. Ole Martin male NaN 0 0 Fa 265302 7.3125 NaN S malemalemale

Визуализация

Метод plot() строит ряд как есть, resample("10s").mean().plot() сначала усредняет его по десятисекундным интервалам и даёт сглаженную кривую, а hist() строит гистограмму распределения.

tdf["Fare"].plot()
<Axes: xlabel='ts'>

png

tdf["Fare"].resample("10s").mean().plot()
<Axes: xlabel='ts'>

png

tdf["Sex"].hist()
<Axes: >

png