Векторизация на практике: переписываем циклы

Урок учит главному навыку эффективного NumPy: видеть цикл и переписывать его в векторную операцию над всем массивом.

Векторизация на практике — это замена явного Python-цикла по элементам набором операций над целыми массивами, выполняемых в скомпилированном коде.

Мышление: «что сделать со всем массивом»

Главный сдвиг в голове при переходе на NumPy — перестать думать «как перебрать элементы» и начать думать «какую операцию применить ко всему массиву сразу». Почти любой цикл, где итерации независимы (результат одного шага не нужен следующему), векторизуется. Разберём типовые шаблоны от простого к сложному, опираясь на чистый Python для наглядности — а рядом покажем эквивалент NumPy для чтения.

Шаблон 1: арифметика по элементам

Самый прямой случай. Цикл, который применяет формулу к каждому элементу, превращается в одно выражение. Например, перевод температур из Цельсия в Фаренгейт по формуле F = C·9/5 + 32.

celsius = [0, 10, 20, 30, 37, 100]

# Циклом
fahrenheit = []
for c in celsius:
    fahrenheit.append(c * 9 / 5 + 32)

print(fahrenheit)

Вывод:

[32.0, 50.0, 68.0, 86.0, 98.6, 212.0]

В NumPy цикл исчезает целиком (блок для чтения):

import numpy as np
celsius = np.array([0, 10, 20, 30, 37, 100])

fahrenheit = celsius * 9 / 5 + 32   # вся формула — одно выражение
print(fahrenheit)

Вывод:

[ 32.   50.   68.   86.   98.6 212. ]

Здесь celsius * 9, деление и + 32 — всё применяется поэлементно ко всему массиву через broadcasting скаляров. Никакого цикла.

Шаблон 2: условие через маску вместо if

Цикл с if внутри — частый случай. Его векторизуют булевыми масками. Допустим, нужно обнулить отрицательные значения и оставить положительные (операция ReLU). Циклом это так:

data = [3, -2, 7, -5, 0, 8, -1]

# Циклом с условием
relu = []
for x in data:
    relu.append(x if x > 0 else 0)

print(relu)

Вывод:

[3, 0, 7, 0, 0, 8, 0]

В NumPy это делается маской или функцией np.maximum (блок для чтения):

import numpy as np
data = np.array([3, -2, 7, -5, 0, 8, -1])

# Вариант 1: через маску на месте
relu = data.copy()
relu[relu < 0] = 0

# Вариант 2: одной функцией (читается лучше)
relu2 = np.maximum(data, 0)

print(relu)
print(relu2)

Вывод:

[3 0 7 0 0 8 0]
[3 0 7 0 0 8 0]

Шаблон 3: два разных результата по условию — np.where

Когда нужно «если условие, то одно, иначе другое», подходит np.where(условие, значение_если_да, значение_если_нет). Это векторный тернарный оператор. Пример: присвоить метку «чёт»/«нечёт» (числами 1/0).

nums = [1, 2, 3, 4, 5, 6]

# Циклом
labels = []
for n in nums:
    labels.append(1 if n % 2 == 0 else 0)

print(labels)

Вывод:

[0, 1, 0, 1, 0, 1]

Векторно через np.where (для чтения):

import numpy as np
nums = np.array([1, 2, 3, 4, 5, 6])

labels = np.where(nums % 2 == 0, 1, 0)
print(labels)

Вывод:

[0 1 0 1 0 1]

Почему векторизация — это не только скорость

Привыкли думать, что векторизуют код ради скорости, и это правда — но не вся. Векторный код обычно ещё и короче, читаемее и надёжнее. Сравните: цикл с накоплением, проверкой границ и временными переменными против одной строки celsius * 9 / 5 + 32. Векторная версия читается как математическая формула, в ней негде ошибиться с индексами или забыть инициализацию. Меньше кода — меньше места для багов. Кроме того, векторный код декларативен: он говорит что нужно сделать («перевести все температуры»), а не как это перебрать. Это поднимает уровень абстракции — вы рассуждаете о данных целиком, а не о механике перебора. Поэтому стремление к векторизации — это не только оптимизация, но и путь к более ясному, выразительному коду. Даже там, где скорость некритична, векторная формулировка часто предпочтительнее именно из-за читаемости. И наоборот: если векторная версия выходит запутанной и нечитаемой, это сигнал, что, возможно, честный цикл здесь уместнее — ясность важнее догматичной векторизации.

Шаблон 4: накопление — кумулятивные функции

Цикл, накапливающий бегущую сумму, заменяется на np.cumsum (а бегущее произведение — на np.cumprod). Это случаи, которые выглядят зависимыми между итерациями, но имеют готовую векторную форму.

data = [1, 2, 3, 4, 5]

# Бегущая сумма циклом
running = []
total = 0
for x in data:
    total += x
    running.append(total)

print(running)

Вывод:

[1, 3, 6, 10, 15]

В NumPy — это np.cumsum(data), дающее [1, 3, 6, 10, 15] одним вызовом.

Накопление, которое кажется последовательным

Кумулятивные функции заслуживают особого внимания, потому что они ломают интуицию «это последовательно, значит нужен цикл». Бегущая сумма выглядит принципиально последовательной: каждое значение зависит от предыдущего. Казалось бы, векторизовать нельзя. Но NumPy предоставляет cumsum и cumprod, которые вычисляют такие накопления в скомпилированном коде за один проход. Это важный урок: «зависимость между шагами» не всегда означает «нужен Python-цикл» — для распространённых видов зависимости (накопление суммы, произведения, максимума через np.maximum.accumulate) есть готовые векторные функции. Прежде чем писать цикл для последовательного вычисления, проверьте, нет ли для него кумулятивного аналога. Это касается финансовых расчётов (накопленный баланс), физики (пройденный путь по скоростям), статистики (нарастающие итоги) — всё это кумулятивные операции с готовой векторной формой. Лишь когда зависимость по-настоящему сложна и не сводится к стандартному накоплению, цикл оправдан.

Шаблон 5: парные операции через сдвиг (разности)

Очень частый приём — вычислить разности соседних элементов (например, изменение цены день к дню). Циклом это a[i] - a[i-1], а векторно — вычитание сдвинутых срезов или готовая np.diff.

prices = [100, 102, 99, 105, 110]

# Разности соседей циклом
diffs = []
for i in range(1, len(prices)):
    diffs.append(prices[i] - prices[i - 1])

print(diffs)

Вывод:

[2, -3, 6, 5]

В NumPy то же самое — это prices[1:] - prices[:-1] (вычитание двух сдвинутых view) или просто np.diff(prices). Идея «сдвинутых срезов» — мощный векторный приём: операции над соседями выражаются через a[1:] и a[:-1] без цикла.

Сдвинутые срезы: мощный приём для соседей

Приём «сдвинутых срезов» заслуживает отдельного внимания, потому что он раскрывает целый класс задач. Идея проста: чтобы работать с парами соседних элементов, берут два среза одного массива со сдвигом на единицу — a[1:] («все, кроме первого») и a[:-1] («все, кроме последнего») — и оперируют ими как двумя выровненными массивами. Их разность a[1:] - a[:-1] даёт изменения между соседями; их сумма — суммы соседних пар; их сравнение a[1:] > a[:-1] — где значение выросло относительно предыдущего (восходящие участки). Расширяя приём, можно работать со сдвигами на k: a[k:] и a[:-k] сопоставляют элементы, отстоящие на k позиций. Так векторизуются скользящие разности, обнаружение пересечений порога, поиск локальных экстремумов (где a[1:-1] больше обоих соседей). Всё это — без единого цикла, через выровненные срезы, которые к тому же являются представлениями и не копируют данные. Освоив сдвинутые срезы, вы перестанете писать циклы вида «for i: a[i] и a[i-1]» — а таких задач в обработке временных рядов и сигналов очень много.

Когда цикл всё-таки нужен

Векторизуется не всё. Если каждая итерация существенно зависит от предыдущей нелинейным образом (сложная рекуррентность, которую не выражает cumsum/cumprod), или внутри сложная ветвящаяся логика с побочными эффектами, иногда честный цикл понятнее и достаточен. Не стоит выкручивать неестественную векторизацию ценой нечитаемости — но в 90% числовых задач векторная форма и быстрее, и короче.

Таблица: цикл → векторная форма

Цикл делаетВекторная замена
формула к каждому элементуa * k + b и т. п.
if/else для значенияnp.where(cond, x, y)
обнулить/обрезать по условиюмаска a[a<0]=0, np.clip
бегущая сумма/произведениеnp.cumsum, np.cumprod
разности соседейnp.diff, a[1:]-a[:-1]
сумма/среднее/максa.sum(), a.mean(), a.max()

Подводные камни

  • Скрытый цикл через list comprehension по массиву. np.array([f(x) for x in a]) теряет скорость — ищите векторную форму f.
  • Векторизация ценой памяти. Иногда векторная форма создаёт огромные временные массивы; следите за размером промежуточных результатов.
  • Насильная векторизация рекуррентностей. Не всё выражается без цикла; не жертвуйте читаемостью ради мнимой элегантности.
  • Путать np.where(cond, x, y) и np.where(cond). С тремя аргументами — выбор значений; с одним — индексы.

Алгоритм векторизации: пошагово

Когда перед вами цикл, который хочется ускорить, действуйте по схеме. Шаг первый — определите, независимы ли итерации: зависит ли результат шага от результатов предыдущих? Если нет (каждый элемент обрабатывается сам по себе) — векторизация почти наверняка возможна напрямую. Шаг второй — выделите тело цикла и посмотрите, что оно делает с одним элементом: арифметику (→ выражение над массивом), условие (→ np.where или маска), вызов функции (→ ufunc). Шаг третий — если в теле есть обращение к соседям (a[i-1], a[i+1]), выразите это сдвинутыми срезами. Шаг четвёртый — если итерации зависимы, проверьте, не сводится ли зависимость к готовой кумулятивной функции (cumsum, cumprod); если да — используйте её, если нет — возможно, цикл здесь оправдан. Шаг пятый — после векторизации сверьте результат с исходным циклом на небольшом примере, чтобы убедиться в эквивалентности. Эта дисциплина превращает векторизацию из искусства в ремесло: большинство циклов раскладываются по этим шагам в предсказуемую векторную форму.

Лучшие практики

  • Увидев цикл по элементам массива, спросите: можно ли выразить это операцией над всем массивом?
  • Условия переводите в маски и np.where; операции над соседями — в сдвинутые срезы.
  • Используйте готовые векторные функции (cumsum, diff, clip) вместо ручных циклов.
  • Оставляйте цикл там, где зависимость итераций неустранима, а не плодите нечитаемый код.

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

Итог

  • Векторизация — это замена цикла по элементам операциями над всем массивом.
  • Арифметика → выражения; if/else → np.where и маски; накопление → cumsum/cumprod.
  • Операции над соседями выражаются сдвинутыми срезами (a[1:]-a[:-1]) или np.diff.
  • Не всё нужно векторизовать: сложные рекуррентности и ветвления иногда честнее оставить циклом.
Проверьте себя
1. Какой векторной операцией NumPy заменяют цикл с условием «if x > 0: x else 0» (ReLU)?
Anp.array([x if x > 0 else 0 for x in a])
BМаской a[a < 0] = 0 или функцией np.maximum(a, 0)
Cnp.sort(a)
Da.reshape(-1, 1)
2. Чем удобен np.where(условие, x, y) при переписывании циклов?
AОн сортирует массив по условию
BЭто векторный тернарный оператор: выбирает x там, где условие True, и y там, где False, без цикла
CОн возвращает только индексы, где условие истинно
DОн работает лишь с булевыми массивами на входе и выходе
3. Как векторно вычислить разности соседних элементов массива a без цикла?
Aa.sum()
Ba[1:] - a[:-1] (вычитание сдвинутых срезов) или np.diff(a)
Ca * a
Dnp.sort(a) - a
Поддержать проект