Чем ndarray отличается от list
numpy.ndarray — это настоящий массив в смысле модуля 4. Под капотом — непрерывный блок памяти, в котором лежат значения фиксированного размера, выбранного через dtype. Никаких PyObject-обёрток на каждое значение. Доступ по индексу — арифметика адресов, как в C.
import numpy as np
arr = np.array([10, 20, 30, 40, 50], dtype='int32')
print(arr.dtype) # int32
print(arr.itemsize) # 4 байта
print(arr.nbytes) # 5 * 4 = 20 байт буфера
print(arr.data) # <memory at 0x...>
itemsize * len(arr) = nbytes — плотная упаковка. На 1М int32 = 4 МБ, ровно. Сравните с list[int] на 1М ~36 МБ (из урока 02-python-list-internals).
Anatomy of ndarray: dtype + shape + strides
Главные поля ndarray:
- dtype — тип значений в буфере. Определяет itemsize и интерпретацию байт. Примеры: int8 (1 байт), int32 (4), int64 (8), float64 (8), bool (1).
- shape — tuple размерностей. (1000,) для 1D из 1000 элементов; (100, 50) для 100x50 матрицы.
- strides — tuple шагов в байтах между соседними элементами по каждой оси.
- data — указатель на буфер.
shape и strides вместе позволяют ndarray быть многомерным без копирования: транспонирование, срез, reshape часто меняют только метаданные, а буфер остаётся тот же.
Буфер 48 байт, стрелки strides показывают как двигаться по строкам/столбцам.
arr[i, j] находится по формуле:
address = base + i * strides[0] + j * strides[1]
То же base + i * sizeof, что для 1D массива, просто на много измерений.
C-order vs F-order: row-major vs column-major
Когда буфер 2D, как разложить элементы линейно? Два варианта:
- C-order (row-major) — строки идут подряд.
[row0, row1, row2, ...]. Default в numpy. - F-order (column-major) — столбцы идут подряд.
[col0, col1, col2, ...]. Default в Fortran, R, MATLAB.
Для 3x4 матрицы:
- C-order: [10,20,30,40,50,60,70,80,90,100,110,120]
- F-order: [10,50,90,20,60,100,30,70,110,40,80,120]
Разница важна для cache locality: проход по строкам в C-order работает с подряд лежащими байтами (хороший cache). Проход по столбцам в C-order делает скачки strides[0] байт — может быть cache-miss на каждом шаге.
import numpy as np
import time
n = 5000
arr = np.random.rand(n, n) # C-order, float64
# проход по строкам (хороший cache)
t0 = time.perf_counter()
for i in range(n):
_ = arr[i, :].sum()
t1 = time.perf_counter()
print(f"row scan: {(t1-t0)*1000:.2f} ms")
# проход по столбцам (плохой cache в C-order)
t0 = time.perf_counter()
for j in range(n):
_ = arr[:, j].sum()
t1 = time.perf_counter()
print(f"column scan: {(t1-t0)*1000:.2f} ms")
На обычном железе колоночный скан в C-order медленнее в 2-5 раз — это spatial locality в чистом виде.
Когда переходить с list на ndarray
Ситуации, в которых list плохо работает, а ndarray — хорошо:
- Численные данные больше 100k элементов. Память отличается в 4-10 раз, скорость batch-операций — в 30-100.
- Операции по всему массиву. sum, mean, std, dot product — все vectorized.
- Многомерные данные. Матрицы, тензоры. list of lists — это уже совсем плохо.
- Срезы без копирования.
arr[100:200]в numpy это view (метаданные), без копирования буфера. В list — slice копирует указатели. - Type stability. Один dtype, никакой динамической типизации. Это позволяет SIMD и JIT-compile через Numba.
Когда list остаётся правильным выбором:
- Маленькие коллекции (десятки-сотни элементов).
- Гетерогенные данные (разные типы в одном списке).
- Частые append/pop с обоих концов (используйте deque).
- Логика, не подаётся под vectorization (сложный if-else на каждом элементе).
pandas DataFrame как ndarray + метаданные
Когда у вас табличные данные (несколько колонок разных типов), правильная структура — pandas.DataFrame. Внутри это набор ndarray (один на колонку или несколько колонок одного dtype в «блоке»). Все преимущества ndarray (плотность, vectorized) + табличная семантика (groupby, join, filter).
import pandas as pd
df = pd.DataFrame({
'user_id': np.arange(1_000_000, dtype='int64'),
'amount': np.random.rand(1_000_000) * 100,
'country': np.random.choice(['RU', 'US', 'CN', 'DE'], 1_000_000),
})
print(df.dtypes)
print(df.memory_usage(deep=True).sum() / 1e6, "MB")
DataFrame с 1М записей часто занимает 20-50 МБ. Эквивалентный list of dicts ([{...}, {...}, ...]) был бы 200-500 МБ.
Бенчмарк: dot product
import time
import numpy as np
n = 1_000_000
# list версии
a_list = [i / n for i in range(n)]
b_list = [(n - i) / n for i in range(n)]
t0 = time.perf_counter()
result = sum(a * b for a, b in zip(a_list, b_list))
t1 = time.perf_counter()
list_ms = (t1 - t0) * 1000
# numpy версии
a_arr = np.array(a_list, dtype='float64')
b_arr = np.array(b_list, dtype='float64')
t0 = time.perf_counter()
result = np.dot(a_arr, b_arr)
t1 = time.perf_counter()
numpy_ms = (t1 - t0) * 1000
print(f"list zip+sum: {list_ms:.2f} ms")
print(f"numpy.dot: {numpy_ms:.2f} ms")
print(f"speedup: {list_ms / numpy_ms:.1f}x")
Типовой результат:
list zip+sum: 75.50 ms
numpy.dot: 0.50 ms
speedup: 151.0x
В 150 раз. Это не разница в алгоритме (оба O(n)). Это разница в константе: native C + SIMD vs Python loop с PyObject. Это и есть главный аргумент в пользу numpy.
Подводный камень: copy vs view
Срез ndarray возвращает view — указатель на тот же буфер с другими metadata. Изменение view меняет оригинал:
arr = np.array([1, 2, 3, 4, 5])
view = arr[1:4]
view[0] = 999
print(arr) # [1, 999, 3, 4, 5]
Это эффективно (без копирования), но опасно (неожиданная мутация). Если нужна копия — view = arr[1:4].copy().
В list срез копирует:
xs = [1, 2, 3, 4, 5]
sl = xs[1:4]
sl[0] = 999
print(xs) # [1, 2, 3, 4, 5] — не изменилось
Это разница семантики, которую надо помнить при переходе с list на ndarray.
Попробуй сам
Сравните память и скорость на одной задаче — посчитать среднее:
import sys
import time
import numpy as np
N = 5_000_000
# list[float]
lst = [i * 0.1 for i in range(N)]
list_bytes = sys.getsizeof(lst) + N * sys.getsizeof(0.1)
t0 = time.perf_counter()
mean_lst = sum(lst) / len(lst)
t1 = time.perf_counter()
list_ms = (t1 - t0) * 1000
# numpy.ndarray
arr = np.arange(N, dtype='float64') * 0.1
arr_bytes = arr.nbytes
t0 = time.perf_counter()
mean_arr = arr.mean()
t1 = time.perf_counter()
numpy_ms = (t1 - t0) * 1000
print(f"list: {list_bytes / 1e6:>7.1f} MB {list_ms:>7.2f} ms")
print(f"numpy: {arr_bytes / 1e6:>7.1f} MB {numpy_ms:>7.2f} ms")
print(f"memory ratio: {list_bytes / arr_bytes:.1f}x")
print(f"speed ratio: {list_ms / numpy_ms:.1f}x")
Скорее всего, в среднем 4x экономия памяти и 100-300x ускорение. Это пороги принятия решения «list или numpy» для DE-задач.
pandas DataFrame: eager evaluation и copy-on-write Polars: lazy API и Apache Arrow backend