Урок курса
Векторная арифметика и 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
Здесь алгоритм перевода в векторный вид состоит из трёх шагов.
- Определить условие и записать его как булеву маску или выражение:
a > 0. - Определить, что подставляется при
Trueи приFalse: приTrue—a[i](то есть сам элемент), приFalse—0. - Вызвать
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?
Продолжить с проверкой и прогрессом
Откройте интерактивный раннер с заданиями урока.
