Урок курса

Векторная арифметика и where/clip

NumPy и pandas: практический тренажёр

В предыдущем уроке мы научились строить булевы маски и комбинировать их через &, | и ~. Теперь займёмся тем, ради чего маски особенно полезны: будем не просто отбирать элементы, а преобразовывать данные — ограничивать диапазоны, подставлять значения по условию и заменять циклы одной строкой кода.

np.clip — ограничение значений диапазоном

np.clip(a, a_min, a_max) решает простую, но часто встречающуюся задачу: убедиться, что все значения массива лежат в допустимом диапазоне. Всё, что ниже a_min, заменяется на a_min; всё, что выше a_max, — на a_max; значения внутри диапазона остаются без изменений.

Функция возвращает новый массив той же формы — исходный a не трогается.

import numpy as np

a = np.array([-2, 0, 3, 7])
result = np.clip(a, 0, 5)
print(result)  # [0 0 3 5]
print(a)       # [-2  0  3  7] — исходный не изменился

Здесь -2 подтягивается до нижней границы 0, 7 обрезается до 5, а 0 и 3 попадают в диапазон и остаются как есть.

Границы передаются скалярами — это стандартный случай. Передача массивов в качестве границ (поэлементные границы) существует, но в этом уроке не рассматривается.

Когда np.clip удобен на практике: нормализация оценок в диапазон [0, 100], ограничение физической величины (например, скорости или температуры) рабочим диапазоном датчика, подготовка входных данных перед логарифмированием — чтобы не получить log(0) или log от отрицательного числа.

np.where — поэлементный условный выбор

np.where(condition, x, y) — это векторная замена конструкции if/else, применённой к каждому элементу массива. Функция проходит по всем позициям: где condition истинно, берёт значение из x; где ложно — из y. Результат — новый массив той же формы.

В этом уроке x и y — либо скаляры, либо массивы ровно той же формы, что condition. Это важная оговорка: broadcasting здесь не рассматривается, и если формы не совпадают, NumPy либо сделает что-то неожиданное, либо выбросит ошибку.

Простейший случай — заменить все отрицательные значения нулём:

import numpy as np

a = np.array([-3, 0, 7, -1, 4])
result = np.where(a > 0, a, 0)
print(result)  # [0 0 7 0 4]

Здесь condition — это a > 0, булев массив вида [False, False, True, False, True]. Там, где True, берётся соответствующий элемент a; там, где False, подставляется скаляр 0.

Исходный массив a не изменяется — np.where всегда возвращает новый объект.

Условие можно собрать из нескольких проверок с помощью & и | — тех же операторов, что используются для булевых масок при индексации. Синтаксис остаётся прежним: каждое условие в скобках, между ними & или |:

a = np.array([1, 6, 3, 8, 2])
out = np.where((a >= 3) & (a <= 7), a * 10, -1)
print(out)  # [-1 60 30 -1 -1]

Здесь элементы от 3 до 7 умножаются на 10, все остальные заменяются на -1. Значение 8 не попадает в диапазон [3, 7], поэтому получает -1; значение 1 и 2 — тоже.

Обратите внимание: x и y вычисляются полностью до того, как применяется условие. Если написать np.where(a != 0, 1 / a, 0), деление 1 / a вычислится для всех элементов, включая нули — и RuntimeWarning появится, даже если нули никогда не попадут в результат. Это не баг np.where, а следствие того, как Python вычисляет аргументы функции.

Поэлементные арифметические операции над массивами

Когда вы пишете a + b для двух массивов NumPy одинаковой формы, сложение происходит не как «сложить два объекта», а как «сложить каждый элемент a с соответствующим элементом b». Так работают все стандартные операторы: +, -, *, /, //, **, %.

import numpy as np

a = np.array([10, 20, 30, 40])
b = np.array([3, 4, 5, 6])

print(a + b)   # [13 24 35 46]
print(a - b)   # [ 7 16 25 34]
print(a * b)   # [ 30  80 150 240]
print(a / b)   # [3.333... 5.0 6.0 6.666...]
print(a // b)  # [3 5 6 6]
print(a ** 2)  # [ 100  400  900 1600]
print(a % b)   # [1 0 0 4]

Несколько моментов, на которые стоит обратить внимание.

/ выполняет true division. Для обычных целочисленных массивов (как int64 в примере выше) результат — вещественный массив float64. Это отличает / от //: np.array([10]) / np.array([3]) даёт [3.333...], а не [3]. Для вещественных и комплексных dtype конкретный тип результата определяется правилами promotion — например, float32 / float32 остаётся float32.

// — floor division, то есть частное округляется вниз до ближайшего целого. Для положительных чисел это совпадает с обычным усечением, но для отрицательных — нет: np.array([-7]) // np.array([2]) даст [-4], а не [-3].

Исходные массивы не изменяются. Любая операция возвращает новый массив — a и b остаются нетронутыми.

Тип результата определяется dtype операндов по правилам promotion. Два int64-массива при сложении, вычитании или умножении дадут int64; если один из операндов float64 — результат будет float64. NumPy выбирает тип автоматически, и явное приведение типов нужно редко.

Операции со скаляром работают так же — скаляр применяется к каждому элементу:

print(a * 2)   # [20 40 60 80]
print(a + 100) # [110 120 130 140]

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

Замена условного Python-цикла векторной операцией

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

Самый простой случай — поэлементная арифметика. Цикл вида:

result = np.empty_like(a)
for i in range(len(a)):
    result[i] = a[i] + b[i]

заменяется на:

result = a + b

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

Сложнее, когда в цикле есть ветвление:

result = np.empty_like(a)
for i in range(len(a)):
    result[i] = a[i] if a[i] > 0 else 0

Здесь алгоритм перевода в векторный вид состоит из трёх шагов.

  1. Определить условие и записать его как булеву маску или выражение: a > 0.
  2. Определить, что подставляется при True и при False: при Truea[i] (то есть сам элемент), при False0.
  3. Вызвать np.where(condition, val_if_true, val_if_false).

Итого:

import numpy as np

a = np.array([-3, 0, 7, -1, 4])
result = np.where(a > 0, a, 0)
print(result)  # [0 0 7 0 4]

Логика та же, что в цикле, только условие, значение при True и значение при False передаются как три аргумента функции.

Если условие составное — его собирают до вызова np.where или прямо внутри него. Например, «заменить на -1 всё, что выходит за диапазон [3, 7]»:

a = np.array([1, 6, 3, 8, 2])
mask = (a >= 3) & (a <= 7)
result = np.where(mask, a, -1)
print(result)  # [-1  6  3 -1 -1]

Предварительно выделять маску в отдельную переменную необязательно — её можно передать прямо в np.where. Отдельная переменная полезна, если условие сложное и его удобнее читать и отлаживать по частям.

Практический критерий: если тело цикла можно описать как «взять одно из двух значений в зависимости от условия», это np.where. Если тело цикла — арифметическая операция без ветвления, это просто поэлементная операция над массивами.

Булевы маски как условие в np.where

В предыдущем уроке сравнительные операции вроде a > 0 или a == 5 возвращали булев массив, который использовался для индексации: a[a > 0] отбирал только подходящие элементы. Здесь тот же булев массив передаётся как аргумент condition в np.where — механика построения маски не меняется, меняется только то, что с ней делают дальше.

Операторы &, |, ~ для комбинирования масок тоже остаются прежними. Единственное отличие от индексации: в np.where маска не отфильтровывает элементы, а управляет выбором между двумя значениями — x при True и y при False.

import numpy as np

a = np.array([2, 8, -1, 5, -3])

# Та же маска, что при индексации:
mask = (a > 0) & (a < 7)
print(a[mask])              # [2 5] — индексация отбирает элементы
print(np.where(mask, a, 0)) # [2 0 0 5 0] — where выбирает из двух значений

Оба вызова используют одну и ту же mask. В первом случае результат короче исходного массива — возвращаются только совпавшие элементы. Во втором случае форма сохраняется: позиции, где условие ложно, получают 0 вместо исходного значения.

Это разделение полезно помнить при выборе инструмента: если нужно сократить массив — булева индексация; если нужно трансформировать массив с сохранением формы — np.where.

Попробуйте решить

Дан массив a = np.array([1, 4, 9, 16]). Какой результат вернёт выражение a ** 0.5?

Продолжить с проверкой и прогрессом

Откройте интерактивный раннер с заданиями урока.

Перейти к интерактивному уроку