7 способов ускорить вычисления NumPy в Python: практическое руководство
Для кого эта статья:
- Разработчики и программисты, желающие улучшить свои навыки в Python и оптимизации кода с использованием NumPy.
- Студенты и начинающие специалисты в области анализа данных и науки о данных, которые стремятся повысить эффективность своих вычислений.
Профессионалы, работающие с большими объемами данных, заинтересованные в ускорении обработки информации и улучшении производительности своих скриптов.
Когда ваш Python-скрипт обрабатывает миллионы элементов данных, а часы безжалостно отсчитывают время, разница между элегантным и эффективным кодом становится критической. NumPy — библиотека, превращающая Python из черепахи в гепарда при работе с массивами данных, но даже здесь есть свои тайные тропы к еще большей производительности. Что если я скажу, что ваш код может работать в 100 раз быстрее без потери читаемости? 🚀 Исследуем семь проверенных стратегий, превращающих обычные вычисления в молниеносные операции.
Не тратьте время на изобретение велосипеда! На курсе Python-разработки от Skypro вы освоите не только базовый синтаксис, но и профессиональные приемы работы с NumPy для высокопроизводительных вычислений. Наши студенты учатся писать код, который работает быстрее в 10-100 раз, чем наивные реализации — навык, мгновенно выделяющий вас среди других кандидатов на позицию дата-аналитика или Python-разработчика.
Основы оптимизации кода: NumPy vs Python-циклы
Стандартные циклы Python — это классический пример того, как не следует обрабатывать большие массивы данных. Причина проста: Python — интерпретируемый язык с динамической типизацией, и каждая операция в цикле выполняется последовательно, с дополнительными проверками типов на каждой итерации.
NumPy решает эту проблему, перенося вычисления на нижний уровень — в оптимизированный C-код, который работает с однородными массивами фиксированного типа. Это устраняет накладные расходы на проверку типов и интерпретацию.
Алексей Савин, Lead Python Developer
Недавно мне пришлось оптимизировать скрипт обработки данных со спутника — 20 Гб изображений, требующих попиксельной фильтрации. Изначальный код с циклами Python работал почти 4 часа. Первая моя мысль была параллелизировать его через multiprocessing, но я решил сначала просто переписать логику с использованием NumPy. Результат шокировал всю команду: время выполнения сократилось до 2 минут без каких-либо архитектурных изменений. Простая замена циклов на векторизованные операции дала ускорение в 120 раз!
Давайте сравним простой пример: вычисление квадратного корня для каждого элемента большого массива.
# Подход с циклами Python
import math
import time
import numpy as np
# Создаем тестовые данные
size = 10_000_000
data = list(range(size))
# Метод 1: Цикл Python
start = time.time()
result_loop = []
for x in data:
result_loop.append(math.sqrt(x))
print(f"Цикл Python: {time.time() – start:.4f} сек")
# Метод 2: Списковое включение
start = time.time()
result_comp = [math.sqrt(x) for x in data]
print(f"Списковое включение: {time.time() – start:.4f} сек")
# Метод 3: NumPy
start = time.time()
data_np = np.array(data)
result_np = np.sqrt(data_np)
print(f"NumPy: {time.time() – start:.4f} сек")
Типичные результаты на современном компьютере:
| Метод | Время выполнения (сек) | Ускорение относительно цикла |
|---|---|---|
| Цикл Python | 2.3500 | 1× |
| Списковое включение | 1.9200 | 1.2× |
| NumPy | 0.0380 | 61.8× |
Ключевые причины превосходства NumPy:
- Память: однородные массивы с фиксированным типом более эффективны, чем списки Python с объектами произвольного типа
- Векторизация: операции выполняются над всем массивом сразу, а не поэлементно
- Компилированный код: базовые операции реализованы на C, без интерпретации Python
- SIMD-инструкции: современные CPU поддерживают одновременную обработку нескольких элементов одной инструкцией

Векторизация с numpy.vectorize: приёмы и подводные камни
Функция numpy.vectorize часто воспринимается как "волшебная палочка" для ускорения обычных функций Python. Однако здесь есть критический нюанс, который многие упускают: vectorize не выполняет настоящей векторизации на уровне C!
Эта функция просто создаёт обёртку, которая применяет вашу Python-функцию к каждому элементу массива. По сути, это синтаксический сахар для цикла Python, а не истинное ускорение вычислений.
Марина Соколова, Data Scientist
Долго не могла понять, почему мой "оптимизированный" код с numpy.vectorize работает даже медленнее обычного цикла. На проекте прогнозирования временных рядов мне требовалось применить сложную функцию трансформации к каждому элементу массива из 5 миллионов значений. Я потратила два дня, пытаясь ускорить процесс с помощью vectorize, прежде чем обнаружила, что она не делает того, что я ожидала. Реальный прорыв произошёл, когда я переписала функцию, используя встроенные универсальные функции NumPy и операции broadcasting. Время обработки уменьшилось с 25 минут до 8 секунд! Этот случай научил меня всегда проверять, действительно ли выбранный метод даёт настоящую векторизацию.
Давайте рассмотрим типичный пример использования vectorize и сравним его с другими подходами:
import numpy as np
import time
# Обычная Python-функция
def my_func(x):
if x < 0.5:
return x * 2
else:
return x ** 2
# Создаем данные
size = 10_000_000
data = np.random.random(size)
# Метод 1: Python-цикл
start = time.time()
result_loop = [my_func(x) for x in data]
print(f"Цикл Python: {time.time() – start:.4f} сек")
# Метод 2: numpy.vectorize
start = time.time()
vectorized_func = np.vectorize(my_func)
result_vectorized = vectorized_func(data)
print(f"numpy.vectorize: {time.time() – start:.4f} сек")
# Метод 3: Нативные операции NumPy
start = time.time()
result_numpy = np.where(data < 0.5, data * 2, data ** 2)
print(f"Нативный NumPy: {time.time() – start:.4f} сек")
Вот сравнительные результаты:
| Метод | Время выполнения (сек) | Ускорение |
|---|---|---|
| Цикл Python | 2.1200 | 1× |
| numpy.vectorize | 2.3600 | 0.9× (медленнее!) |
| Нативный NumPy | 0.0420 | 50.5× |
Когда всё же стоит использовать numpy.vectorize?
- Читаемость кода — когда вам важнее ясность намерений, чем максимальная производительность
- Прототипирование — для быстрого создания рабочей версии, которую потом можно оптимизировать
- Сложная логика — когда ветвление слишком сложно для
np.where - Применение существующих функций — если у вас уже есть готовая функция и нет времени её переписывать
Приём для улучшения производительности vectorize — использование параметра otypes для явного указания типа возвращаемого значения:
# Без указания типа
vectorized_slow = np.vectorize(my_func)
# С явным указанием типа результата
vectorized_faster = np.vectorize(my_func, otypes=[float])
Это позволяет NumPy заранее выделить память правильного размера и типа, что даёт некоторый выигрыш в скорости (обычно 10-20%).
Мощь универсальных функций (ufuncs) для быстрых массивов
Универсальные функции (ufuncs) — настоящее сокровище библиотеки NumPy. Это предварительно скомпилированные функции, которые поэлементно обрабатывают массивы с максимальной эффективностью. В отличие от vectorize, здесь мы получаем настоящую векторизацию, выполняемую на уровне C с возможной поддержкой SIMD-инструкций вашего процессора. 💪
NumPy поставляется с более чем 60 встроенными ufuncs, охватывающими математические, тригонометрические, битовые и логические операции. Вот некоторые из часто используемых:
- Математические: add, subtract, multiply, divide, power, sqrt, exp, log
- Тригонометрические: sin, cos, tan, arcsin, arccos, arctan
- Сравнения: greater, greaterequal, less, lessequal, equal, not_equal
- Логические: logicaland, logicalor, logicalxor, logicalnot
Главная особенность ufuncs — они автоматически применяются ко всем элементам массива без необходимости в явных циклах, обеспечивая оптимальное использование кэша CPU и векторных инструкций.
Рассмотрим простой пример, вычисляющий выражение (sin(x) \cdot e^{-x}) для массива значений:
import numpy as np
import math
import time
# Создаем массив значений
size = 50_000_000
x = np.linspace(0, 10, size)
x_list = x.tolist() # Для проверки с обычными функциями Python
# Метод 1: Функции Python в списковом включении
start = time.time()
result_py = [math.sin(val) * math.exp(-val) for val in x_list]
py_time = time.time() – start
print(f"Python functions: {py_time:.4f} сек")
# Метод 2: NumPy ufuncs
start = time.time()
result_np = np.sin(x) * np.exp(-x)
np_time = time.time() – start
print(f"NumPy ufuncs: {np_time:.4f} сек")
print(f"Ускорение: {py_time / np_time:.1f}x")
Ufuncs также поддерживают дополнительные операции через специальные методы:
- reduce — применяет функцию вдоль оси, уменьшая размерность массива
- accumulate — накапливает результаты, сохраняя промежуточные значения
- outer — применяет функцию к каждой паре элементов из двух массивов
- at — применяет функцию к указанным элементам массива по индексам
Пример использования методов ufunc:
import numpy as np
# Создадим массив
arr = np.array([1, 2, 3, 4])
# reduce — найдем произведение всех элементов
product = np.multiply.reduce(arr) # 1*2*3*4 = 24
# accumulate — получим промежуточные результаты
running_product = np.multiply.accumulate(arr) # [1, 2, 6, 24]
# outer — умножение каждого элемента на каждый
multiplication_table = np.multiply.outer(arr, arr)
# [[1, 2, 3, 4],
# [2, 4, 6, 8],
# [3, 6, 9, 12],
# [4, 8, 12, 16]]
Секрет создания собственных высокопроизводительных ufuncs — использование библиотеки Numba с декоратором @vectorize:
import numpy as np
from numba import vectorize
# Создаем собственную ufunc для вычисления гиперболического тангенса
@vectorize(['float64(float64)'])
def my_tanh(x):
# Оптимизированный вариант гиперболического тангенса
return (np.exp(x) – np.exp(-x)) / (np.exp(x) + np.exp(-x))
# Используем как обычную ufunc
data = np.linspace(-5, 5, 1000000)
result = my_tanh(data)
Такой подход позволяет создавать функции, работающие со скоростью, сопоставимой с встроенными ufuncs NumPy.
Broadcasting и индексирование: незаметные ускорители кода
Broadcasting — один из самых элегантных механизмов NumPy, позволяющий выполнять операции между массивами разных форм без создания временных копий. Это значительно экономит память и ускоряет вычисления. 🧠
Принцип broadcasting прост: если формы двух массивов совместимы (т.е. соответствующие размерности либо равны, либо одна из них равна 1), NumPy "растягивает" меньший массив до размеров большего, не создавая реальных копий данных.
import numpy as np
# Создадим массивы разной формы
a = np.array([[1, 2, 3], [4, 5, 6]]) # Форма (2, 3)
b = np.array([10, 20, 30]) # Форма (3,)
# Broadcasting позволяет выполнить сложение без создания копий
c = a + b # Результат: [[11, 22, 33], [14, 25, 36]]
# Без broadcasting нам пришлось бы делать так:
b_expanded = np.tile(b, (2, 1)) # Создаем копию b размером (2, 3)
c_manual = a + b_expanded # Тот же результат, но больше памяти и медленнее
Правила совместимости для broadcasting:
- Если массивы имеют разное количество измерений, к форме меньшего массива добавляются единицы в начало, пока количество измерений не сравняется
- Две размерности совместимы, если они равны или одна из них равна 1
- Если эти условия не выполняются, broadcasting невозможен, и NumPy выдаст ошибку
Практический пример: нормализация строк матрицы путем деления каждой строки на её сумму:
import numpy as np
# Создаем матрицу
matrix = np.random.rand(1000, 1000)
# Неэффективный способ (с циклом)
start = time.time()
result_loop = np.zeros_like(matrix)
for i in range(matrix.shape[0]):
row_sum = np.sum(matrix[i])
result_loop[i] = matrix[i] / row_sum
print(f"Цикл: {time.time() – start:.4f} сек")
# Эффективный способ (с broadcasting)
start = time.time()
row_sums = np.sum(matrix, axis=1)
# Превращаем одномерный массив в столбец для правильного broadcasting
row_sums = row_sums.reshape(-1, 1)
result_broadcast = matrix / row_sums
print(f"Broadcasting: {time.time() – start:.4f} сек")
Расширенное индексирование — еще один мощный инструмент NumPy, который может значительно ускорить манипуляции с данными. Особенно эффективны булевы маски и продвинутые методы индексирования:
import numpy as np
import time
# Создаем большой массив
size = 10_000_000
data = np.random.normal(0, 1, size)
# Задача: заменить все отрицательные значения на их квадраты
# Метод 1: цикл с условием (неэффективно)
start = time.time()
result_loop = data.copy()
for i in range(size):
if result_loop[i] < 0:
result_loop[i] = result_loop[i]**2
print(f"Цикл: {time.time() – start:.4f} сек")
# Метод 2: булева маска (эффективно)
start = time.time()
result_mask = data.copy()
mask = result_mask < 0
result_mask[mask] = result_mask[mask]**2
print(f"Маска: {time.time() – start:.4f} сек")
# Метод 3: np.where (наиболее эффективно)
start = time.time()
result_where = np.where(data < 0, data**2, data)
print(f"np.where: {time.time() – start:.4f} сек")
Производительность различных методов индексирования:
| Операция | Относительная скорость | Использование памяти | Когда применять |
|---|---|---|---|
| Базовое индексирование | ★★★★★ | Минимальное | Простой доступ к элементам |
| Булевы маски | ★★★★☆ | Среднее | Фильтрация по условию |
| Fancy индексирование | ★★★☆☆ | Высокое | Нерегулярный доступ |
| np.where | ★★★★★ | Среднее | Условная замена |
| np.take | ★★★★☆ | Низкое | Выборка по индексам |
Профилирование и оценка производительности вычислений NumPy
Оптимизация без измерений — это гадание. Прежде чем применять любые приемы ускорения, критически важно понять, где именно код тратит больше всего времени. Python предлагает несколько инструментов профилирования, идеально подходящих для анализа производительности NumPy-операций.
Базовый подход — использование модуля timeit для измерения времени выполнения отдельных фрагментов кода:
import numpy as np
import timeit
# Сравним три метода вычисления суммы квадратов
setup = "import numpy as np; x = np.random.rand(1000000)"
# Метод 1: Цикл Python
python_loop = """
result = 0
for i in range(len(x)):
result += x[i]**2
"""
# Метод 2: Списковое включение + sum
list_comp = "sum([val**2 for val in x])"
# Метод 3: Векторизованный NumPy
numpy_vec = "np.sum(x**2)"
# Измеряем время (повторяем каждый тест несколько раз)
t1 = timeit.timeit(python_loop, setup=setup, number=10)
t2 = timeit.timeit(list_comp, setup=setup, number=10)
t3 = timeit.timeit(numpy_vec, setup=setup, number=10)
print(f"Цикл Python: {t1:.4f} сек")
print(f"Списковое включение: {t2:.4f} сек")
print(f"Векторизованный NumPy: {t3:.4f} сек")
Для более глубокого анализа используйте специализированные профилировщики:
- cProfile — встроенный профилировщик Python, показывающий время выполнения каждой функции
- line_profiler — построчный профилировщик, позволяющий увидеть, какие строки кода самые медленные
- memory_profiler — отслеживает использование памяти построчно
- py-spy — профилировщик реального времени, не влияющий на производительность
Пример использования cProfile:
import cProfile
import numpy as np
def process_data():
# Создаем большие массивы
a = np.random.rand(5000, 5000)
b = np.random.rand(5000, 5000)
# Выполняем различные операции
c = a.dot(b)
d = np.linalg.svd(c)
e = np.fft.fft2(a)
return np.sum(e)
# Запускаем профилирование
cProfile.run('process_data()', sort='cumtime')
Типичные узкие места в NumPy-коде и способы их решения:
- Создание временных массивов — используйте
outпараметр в функциях NumPy для записи результатов в существующий массив - Копирование больших массивов — работайте с представлениями (
view) вместо копий, где возможно - Многократный доступ к элементам — извлекайте подмассивы одной операцией, а не отдельными индексами
- Последовательное применение функций — объединяйте операции, чтобы минимизировать проходы по данным
- Неэффективное хранение — выбирайте правильный dtype (например, float32 вместо float64, если точность позволяет)
При анализе производительности NumPy-кода всегда учитывайте не только время выполнения, но и использование памяти. Некоторые оптимизации могут ускорить код за счет дополнительной памяти, что не всегда приемлемо для больших наборов данных.
Интересный факт: для очень больших массивов время доступа к памяти (а не CPU-вычисления) часто становится основным ограничением производительности. В таких случаях оптимизация порядка доступа к элементам (locality of reference) может дать значительный выигрыш.
Оптимизация NumPy-кода — это искусство балансирования между читаемостью, производительностью и использованием памяти. Не поддавайтесь искушению преждевременной оптимизации — сначала напишите ясный код, затем измерьте его производительность, и только потом оптимизируйте узкие места. Помните, что хорошо структурированный векторизованный код на NumPy часто не только быстрее, но и понятнее, чем запутанные оптимизации на чистом Python. В конечном счете, лучший код — тот, который решает вашу задачу в требуемых временных рамках и при этом остается понятным для вас и ваших коллег через полгода после написания.