Работа с данными: numpy, pandas, форматы, типичные ошибки производительности
Питон медленный. Это правда ровно до того момента, пока вы не перестаёте писать циклы на Python и не начинаете описывать что нужно сделать с массивом целиком. Весь стек данных — numpy, pandas, Arrow, polars — построен вокруг одной идеи: вынести цикл из интерпретатора в скомпилированный код, а Python оставить языком, на котором вы этот цикл описываете.
Отсюда следует главный навык этой темы, и он не про синтаксис. Он про умение в любой момент ответить на два вопроса: «где сейчас крутится цикл — в CPython или в C?» и «сколько байт только что скопировалось?». Почти все катастрофы производительности в data-коде — это незамеченный ответ «в CPython» на первый вопрос или «полтора гигабайта» на второй.
Статья предполагает, что базовый Python вы уже знаете: устройство объектов разобрано в
«Модели данных», встроенные коллекции — в
«Коллекциях», а азы чтения файлов и работы с числами —
в курсе «Программирование с нуля». Здесь речь про
профессиональный слой: модель памяти массива, семантика копирования в pandas, форматы
хранения и измеримая цена каждого решения. Все числа ниже получены на CPython 3.12,
NumPy 2.5, pandas 3.0, PyArrow 25 — воспроизводятся одной командой python -m timeit
или приведённым кодом.
Цена абстракции: почему список чисел — это не массив
Начнём с честной арифметики. Список из миллиона float в Python:
import sys
import numpy as np
py = [float(i) for i in range(1_000_000)]
arr = np.arange(1_000_000, dtype=np.float64)
print(sys.getsizeof(py)) # 8_000_056 — только массив указателей
print(sys.getsizeof(1.0)) # 24 — и ещё столько на КАЖДЫЙ объект float
print(arr.nbytes) # 8_000_000 — и это всё
Список — это массив указателей на объекты. Миллион чисел стоит 8 МБ указателей плюс
24 МБ самих объектов float, то есть 32 МБ вместо 8 МБ, и элементы разбросаны по куче,
убивая локальность кеша (подробно про это — в
«Кеш и локальность»). Каждая
операция a + b над элементами — это разыменование двух указателей, диспетчеризация
nb_add, аллокация нового объекта и инкремент счётчиков ссылок.
ndarray устроен противоположным образом: один непрерывный буфер однотипных значений плюс
маленький заголовок с метаданными.
Из этой картинки следуют все свойства numpy, которые обычно заучивают наизусть:
| Свойство | Почему так |
|---|---|
| Массив однотипный | В буфере лежат значения фиксированного размера itemsize |
| Размер фиксирован | Нет «дописать в конец» без перевыделения всего буфера |
a.T мгновенно |
Транспонирование меняет местами strides, байты не двигаются |
a[1:5] мгновенно |
Новый заголовок со смещением, буфер общий |
a[a > 5] не мгновенно |
Результат не выражается через strides — нужен новый буфер |
a.reshape(...) обычно бесплатен |
Пока новую форму можно выразить через шаги в том же буфере |
a = np.arange(12, dtype=np.int32).reshape(3, 4)
view = a[:, 1:3] # представление
copy = a[:, [1, 3]] # fancy indexing -> копия
print(np.shares_memory(a, view)) # True
print(np.shares_memory(a, copy)) # False
view[0, 0] = 99
print(a[0, 1]) # 99 — изменили исходный массив
Это первая большая грабля: вы не всегда знаете, что перед вами. Функция, принимающая
массив и делающая arr[arr < 0] = 0, «испортит» данные вызывающего, если получила
представление, и не испортит, если получила копию. Лечится либо явным контрактом («функция
изменяет аргумент на месте»), либо arr = np.array(arr, copy=True) на входе, либо
arr.flags.writeable = False для защиты от случайной записи. Спрашивайте у
np.shares_memory, а не у интуиции.
C-order, F-order и почему sum(axis=0) иногда вдвое медленнее
strides определяют порядок обхода памяти, а он определяет попадания в кеш:
m = np.ones((4000, 4000)) # C-order: строки лежат подряд
mf = np.asfortranarray(m) # F-order: подряд лежат столбцы
# измерено: m.sum(axis=1) -> 5 мс, m.sum(axis=0) -> 8 мс
# измерено: mf.sum(axis=1) -> 8 мс, mf.sum(axis=0) -> 5 мс
Разница на ровном месте — 60 % времени. Практический вывод: если вы гоняете редукции вдоль
одной оси в горячем цикле, приведите массив к соответствующему порядку один раз через
np.ascontiguousarray / np.asfortranarray, а не платите за перепрыгивание по памяти на
каждой итерации. Библиотеки линейной алгебры (BLAS/LAPACK под капотом numpy.linalg) делают
это сами, но ваш собственный код — нет.
Broadcasting: правило, которое экономит код и съедает память
Broadcasting — это способ выполнить операцию над массивами разной формы без явного копирования. Правило выравнивания читается справа налево:
Ключевой момент: растяжение виртуальное, оно реализовано через stride = 0 по растянутой
оси. Но результат операции — уже настоящий массив, и вот тут ловушка:
a = np.arange(10_000, dtype=np.float64) # 80 КБ
d = a[:, None] - a[None, :] # форма (10000, 10000)
print(d.nbytes / 1e9) # 0.8 — восемьсот мегабайт из 80 килобайт
Матрица попарных расстояний на 10 тысячах точек — 0.8 ГБ, на 50 тысячах — 20 ГБ. Это самая
частая причина MemoryError в ML-коде. Лечится либо разбиением на блоки, либо готовыми
функциями, которые считают блочно (scipy.spatial.distance.cdist,
sklearn.metrics.pairwise_distances с chunk_size), либо переформулировкой задачи так,
чтобы попарная матрица вообще не понадобилась. Перед любым выражением вида x[:, None]
считайте произведение размерностей в уме.
Векторизация: сколько именно она даёт
Обычно пишут «numpy в сто раз быстрее». Реальность интереснее — выигрыш зависит от того, упирается ли операция в вычисления или в пропускную способность памяти:
import math, time
import numpy as np
for n in (10_000, 100_000, 5_000_000):
a = np.random.random(n)
py = a.tolist()
# чистый Python
s1 = sum(math.sqrt(x) * 2.0 for x in py)
# numpy
s2 = (np.sqrt(a) * 2.0).sum()
Измеренные времена одной итерации:
| Размер | Чистый Python | NumPy | Ускорение |
|---|---|---|---|
| 10 000 | 0.41 мс | 0.018 мс | ×23 |
| 100 000 | 3.9 мс | 0.21 мс | ×19 |
| 5 000 000 | 195 мс | 34.6 мс | ×6 |
На маленьких массивах, помещающихся в кеш, выигрыш ×20; на пятимиллионном — всего ×6,
потому что numpy тут упирается в память: выражение np.sqrt(a) * 2.0 создаёт два
временных массива по 40 МБ. Убираем их — и получаем обратно почти всё:
x = np.random.random(20_000_000)
y = x * 2 + 1 # 98 мс: два временных массива по 160 МБ
np.multiply(x, 2, out=x) # 14 мс суммарно: ничего не аллоцируется
np.add(x, 1, out=x)
Семикратная разница — только за счёт out=. Правило: в горячем коде над большими массивами
используйте out=, in-place операторы (x *= 2), np.einsum для свёрток и
numexpr/numba, если выражение длинное. Для короткого пути «одна формула — один проход»
np.einsum('i,i->', a, a) оказался в 15 раз быстрее наивного (a * a).sum(), потому что не
материализует промежуточный массив.
И отдельно — np.vectorize не векторизует. Это цикл на Python с косметикой:
f = np.vectorize(lambda x: x * x + 1)
small = np.arange(200_000, dtype=np.float64)
f(small) # 34 мс
small * small + 1 # 1.19 мс — в 29 раз быстрее
В документации это написано прямым текстом: «provided primarily for convenience, not for
performance» (docs).
Если операцию нельзя выразить через ufunc — берите numba.njit или Cython, см.
«Производительность».
Численные грабли numpy
Массив — это не «список, только быстрый». Это фиксированная машинная арифметика со всеми последствиями.
# 1. Переполнение целых — тихое, по правилам типа
x = np.array([200], dtype=np.uint8)
print(x + np.uint8(100)) # [44] — 300 mod 256, только RuntimeWarning
# 2. Накопление в float32 теряет точность
f32 = np.full(10_000_000, 0.1, dtype=np.float32)
print(f32.sum()) # 1000000.1 (ошибка ~0.1)
print(f32.sum(dtype=np.float64)) # 1000000.0149011612 — аккумулятор шире
print(10_000_000 * 0.1) # 1000000.0
# 3. NaN не равен ничему, включая себя
print(np.nan == np.nan) # False
print(np.isnan(np.nan)) # True — только так
# 4. Сравнение float на равенство
print(0.1 + 0.2 == 0.3) # False
print(np.isclose(0.1 + 0.2, 0.3)) # True
print(np.allclose(a, b)) # для массивов
Про sum(dtype=...) знают мало, а это дешёвый способ починить точность, не переходя на
float64 для хранения: данные остаются 4-байтовыми, а аккумулятор — 8-байтовый. Кстати,
ndarray.sum() уже использует попарное суммирование, поэтому ошибка на порядок меньше, чем
у наивного цикла; для критичных случаев есть math.fsum (точное, но медленное).
Отдельная тема — миграция на NumPy 2.x. NEP 50
изменил правила приведения типов: Python-скаляры теперь «слабые», и результат определяется
типом массива. np.float32(1.0) + 1.0 даёт float32, а не float64 — это чинит старую
боль «массив незаметно раздулся вдвое», но может изменить точность в существующем коде.
Плюс убраны алиасы np.float_, np.unicode_, изменена семантика np.array(x, copy=False)
(теперь бросает исключение, если копия неизбежна; для старого поведения — np.asarray).
Перед обновлением прогоните ruff с правилом NPY201
и читайте NumPy 2.0 migration guide.
pandas: DataFrame — это набор типизированных колонок
Ментальная модель, из которой следует всё остальное: DataFrame — не таблица строк, а
словарь колонок плюс индекс. Каждая колонка (Series) хранит один массив одного типа.
Практические следствия, которые стоят денег:
1. Добавить колонку дёшево, добавить строку — дорого. Колонка — это просто ещё один
массив в словаре. Строка требует перевыделения каждого массива. Отсюда правило: собирайте
данные в список кортежей/словарей и создавайте DataFrame один раз.
2. Тип колонки — главный рычаг памяти. Измерено на 200 000 строк с четырьмя уникальными значениями:
| dtype | Память | .str.upper() |
|---|---|---|
object (Python-строки) |
16.8 МиБ | 40 мс |
str (Arrow, по умолчанию с pandas 3.0) |
3.5 МиБ | 5 мс |
category |
0.19 МиБ | 4 мс |
Разница между object и category — в 88 раз. Для колонок с низкой кардинальностью
(город, статус, код валюты, тип события) category — самое дешёвое улучшение из
существующих. Смотрите на реальный размер, а не на df.info() по умолчанию:
print(df.memory_usage(deep=True)) # deep=True учитывает сами Python-объекты
df = df.astype({"city": "category", "status": "category"})
3. int + пропуск = float. Классическая ловушка при чтении CSV:
s = pd.Series([1, 2, None])
print(s.dtype, s.tolist()) # float64 [1.0, 2.0, nan] — идентификаторы стали float!
s2 = pd.Series([1, 2, None], dtype="Int64") # nullable-тип с большой буквы
print(s2.dtype, s2.sum()) # Int64 3
print((s2 > 1).tolist()) # [False, True, <NA>] — трёхзначная логика
Если user_id внезапно стал float64, при значениях больше 2^53 вы молча теряете точность,
а после to_csv получаете 123.0 вместо 123. Nullable-типы (Int64, boolean,
Float64) решают проблему ценой трёхзначной логики: NA в булевом контексте — не False,
а «неизвестно», и if s2[2]: бросит исключение. Это правильно, но требует привычки.
Copy-on-Write: конец эпохи SettingWithCopyWarning
Годами pandas отвечал на присваивание в срез загадочным SettingWithCopyWarning, потому что
результат df[df.a > 1] мог быть как копией, так и представлением — зависело от внутреннего
устройства блоков. С pandas 3.0 включён режим
Copy-on-Write, и семантика
стала предсказуемой: любой результат любой операции ведёт себя как независимая копия, а
физическое копирование откладывается до первой записи.
df = pd.DataFrame({"a": [1, 2, 3], "b": [4, 5, 6]})
df[df.a > 1]["b"] = 0 # ChainedAssignmentError — раньше был Warning
df.loc[df.a > 1, "b"] = 0 # правильно: один вызов .loc
Что это меняет на практике: inplace=True и цепочечное присваивание перестают быть
оптимизацией и становятся источником ошибок; идиомой становится чистый функциональный стиль
с assign, pipe и loc. И приятный побочный эффект — многие пайплайны стали быстрее,
потому что pandas перестал копировать «на всякий случай».
Итерация по строкам — главный антипаттерн
Замер на 200 000 строк, задача «умножить колонку на 1.2»:
| Способ | Время | Относительно вектора |
|---|---|---|
df["amount"] * 1.2 |
0.29 мс | ×1 |
df["amount"].apply(lambda x: x * 1.2) |
32 мс | ×114 |
[r.amount * 1.2 for r in df.itertuples()] |
207 мс | ×713 |
[r["amount"] * 1.2 for _, r in df.iterrows()] |
3045 мс | ×10 660 |
iterrows() в десять тысяч раз медленнее не из-за «интерпретатора» — он на каждой строке
конструирует новый объект Series с индексом. Если вам действительно нужен построчный
проход (обращение к внешнему API, сложный автомат состояний), берите itertuples(): он
в 15 раз быстрее iterrows и сохраняет типы колонок, которые iterrows схлопывает к общему
знаменателю.
Тот же принцип в groupby:
df.groupby("city")["amount"].agg("mean") # 5.9 мс — встроенная реализация на C
df.groupby("city")["amount"].apply(lambda s: s.mean()) # 9.2 мс — вызов Python на группу
Разница невелика при четырёх группах и катастрофична при сорока тысячах: apply вызывает
Python-функцию на каждую группу. Если агрегат выражается через встроенные (sum, mean,
nunique, first, quantile) — используйте agg. Если нужна собственная логика, попробуйте
переформулировать её через transform и векторные операции, и только потом — apply.
Когда apply оправдан: сложная логика над небольшим числом групп, парсинг текста без
векторного аналога, вызовы внешних систем. Но замерьте — часто оказывается, что операция
выражается через .str-аксессор или np.select.
merge, concat и квадратичный рост
Три вещи, которые ломают джойны, и три ключа, которые их чинят:
left = pd.DataFrame({"k": [1, 2, 3], "v": [1, 2, 3]})
right_str = pd.DataFrame({"k": ["1", "2", "3"], "w": [9, 8, 7]})
right_dup = pd.DataFrame({"k": [1, 1, 2], "w": [9, 8, 7]})
# 0. Несовпадение типов ключа: раньше молча давало пустой результат, теперь падает
left.merge(right_str, on="k")
# ValueError: You are trying to merge on int64 and str columns for key 'k'
# 1. validate — проверяет кардинальность связи и падает при дублях в ключе
left.merge(right_dup, on="k", validate="one_to_one")
# MergeError: Merge keys are not unique in right dataset; not a one-to-one merge
# 2. indicator — колонка _merge со значениями left_only / right_only / both
res = left.merge(right_dup, on="k", how="left", indicator=True)
# 3. Джойн по умолчанию не проверяет ничего: строк стало больше, чем было слева
assert len(res) == len(left), f"джойн размножил строки: {len(res)} вместо {len(left)}"
validate= стоит ставить в каждом джойне продакшн-пайплайна. Это тот случай, когда одна
строчка кода превращает молчаливую порчу данных (дубли в отчёте, задвоенная выручка) в
громкое падение на этапе разработки — ровно тот принцип, что и в
«Исключениях»: падать рано и громко.
Второй убийца — concat в цикле. Измерено на 20 колонках:
| Итераций | pd.concat в цикле |
Собрать список и создать один раз |
|---|---|---|
| 1 000 | 0.31 с | — |
| 2 000 | 0.60 с | — |
| 4 000 | 1.41 с | — |
| 8 000 | 4.44 с | 0.018 с |
Время растёт быстрее линейного, потому что каждый concat копирует уже накопленный
результат целиком: суммарно копируется O(n²) байт. Разница на 8000 итераций — в 240 раз.
Правило простое и абсолютное: никогда не накапливайте DataFrame через concat/append в
цикле. Собирайте список кортежей или список кадров и вызывайте pd.concat(parts) один раз.
Чтение файлов: где рождаются грязные типы
df = pd.read_csv(
"events.csv",
usecols=["id", "ts", "amount", "city"], # не читаем ненужные колонки
dtype={"id": "int32", "amount": "float32", "city": "category"},
parse_dates=["ts"], # иначе будет строка
date_format="%Y-%m-%d %H:%M:%S", # явный формат: быстрее и без сюрпризов
)
Что ломается при чтении «по умолчанию»:
- Ведущие нули съедаются.
007в колонке кодов станет7. Лечитсяdtype={"code": "str"}. - Даты не парсятся вовсе, а
parse_datesбезdate_formatугадывает формат по первой строке:01/02/2026— это 1 февраля или 2 января? Всегда задавайте формат явно, иначе на новом файле получите тихой сдвиг дат. - Смешанные типы в колонке дают
objectи потерю производительности в разы. - Часовые пояса.
pd.Timestamp("2026-01-01 12:00", tz="Europe/Moscow")и наивный timestamp — разные вещи; смешивать их в одном столбце нельзя. Храните всё в UTC, конвертируйте на границе отображения.
Для файлов, не помещающихся в память, есть chunksize — итератор по кускам:
total = 0.0
for chunk in pd.read_csv("huge.csv", chunksize=500_000, usecols=["amount"]):
total += chunk["amount"].sum()
Это работает, но если вы дошли до chunksize, скорее всего пора менять формат или
инструмент — об этом ниже.
Форматы: чем вы платите за каждый
Один и тот же набор данных (3 млн строк, три колонки) в разных форматах:
| Формат | Размер | Чтение | Типы | Когда брать |
|---|---|---|---|---|
| CSV | 90 МиБ | 1008 мс | теряются | обмен с людьми и Excel, и только |
| JSON Lines | 207 МиБ | ещё медленнее | частично | логи, потоковый обмен, вложенные структуры |
| Parquet + zstd | 25.8 МиБ | 55 мс | сохраняются | хранение по умолчанию |
| Pickle | 96 МиБ | быстро | сохраняются | никогда для обмена и архива |
.npy / .npz |
как в памяти | быстро, есть mmap | dtype и shape | чистые массивы numpy |
| Arrow IPC / Feather | ≈ Parquet | почти нулевая копия | сохраняются | передача между процессами и языками |
CSV в 3.5 раза больше Parquet и в 18 раз медленнее на чтение — и это ещё оптимистично,
потому что CSV не хранит типы: каждый раз вы платите за парсинг чисел и дат из текста и
рискуете получить object там, где ждали int64.
Parquet: что на самом деле делает read_parquet(filters=...)
Parquet — колоночный формат с блочной организацией: файл делится на row group (горизонтальные блоки строк), внутри каждого row group данные лежат поколоночно (column chunk), а в футере файла хранятся метаданные и статистика min/max по каждой колонке каждого блока. Это даёт две оптимизации:
import pyarrow.parquet as pq
df.to_parquet("big.parquet", compression="zstd", row_group_size=500_000)
pf = pq.ParquetFile("big.parquet")
print(pf.metadata.num_row_groups) # 6
rg = pf.metadata.row_group(0)
print(rg.column(1).statistics.min, rg.column(1).statistics.max) # 1 5
pq.read_table("big.parquet") # 55 мс — все колонки
pq.read_table("big.parquet", columns=["amount"]) # 19 мс — projection pushdown
pq.read_table("big.parquet", filters=[("day", "=", 5)]) # predicate pushdown
Predicate pushdown работает только если данные отсортированы по колонке фильтра. На тех
же 3 млн строк: файл в случайном порядке — 51 мс (min/max каждой группы покрывают весь
диапазон, пропустить нечего), отсортированный по day — 19 мс. Отсюда правило продакшн-
пайплайна: сортируйте по типичному ключу фильтрации перед записью.
Для крупных наборов добавляется партиционирование по директориям (Hive-style):
df.to_parquet("events/", partition_cols=["dt", "city"], compression="zstd")
# events/dt=2026-07-16/city=Москва/part-0.parquet
pd.read_parquet("events/", filters=[("city", "=", "Сочи")]) # читает одну поддиректорию
Грабли партиционирования: слишком мелкие партиции хуже, чем их отсутствие. Тысячи файлов по 200 КБ дают латентность на открытие каждого (особенно в объектном хранилище) и раздутые метаданные. Ориентир — 128–512 МБ на файл. Подробнее про раскладку данных в хранилище — в «Хранилища и форматы» и «Объектные хранилища».
Arrow: формат памяти, а не файла
Apache Arrow часто путают с Parquet. Parquet —
формат на диске (сжатый, кодированный, оптимизированный под размер). Arrow — формат
в памяти: колоночная раскладка, стандартизованная так, что pandas, polars, DuckDB,
Spark и даже R могут читать один и тот же буфер без сериализации. Отсюда
dtype_backend="pyarrow", нулевая копия между процессами и мгновенный pl.from_pandas.
df = pd.read_csv("events.csv", dtype_backend="pyarrow")
print(df.dtypes) # int64[pyarrow], string[pyarrow], double[pyarrow]
Arrow-типы дают настоящие пропуски для всех типов, дешёвые строки (offset-массив вместо миллиона Python-объектов) и словарное кодирование из коробки. Минус — не все операции pandas одинаково быстро работают на Arrow-бэкенде, поэтому проверяйте свои горячие места замером.
Pickle: удобство, за которое платят инцидентом
pickle сериализует почти любой Python-объект и делает это быстро. Но:
- Это исполнение произвольного кода при загрузке.
pickle.loadна недоверенном файле — готовый RCE. Документация говорит об этом прямо (warning). - Это не формат обмена. Читается только Python, зависит от версии библиотеки: pickle DataFrame из pandas 1.x может не открыться в 3.x.
- Это не формат архива. Через год вы не восстановите данные, если класс переименован.
Допустимое применение: короткоживущий кеш внутри одного процесса или между процессами одной
версии приложения (multiprocessing использует pickle внутри — см.
«Конкурентность»). Всё, что переживает деплой или
пересекает границу доверия, — Parquet, Arrow или JSON.
.npy, .npz и memory-mapped массивы
Для чистых массивов numpy есть свой формат, и у него важное свойство — его можно не загружать целиком:
np.save("weights.npy", arr)
np.savez_compressed("model.npz", w=w, b=b)
m = np.load("weights.npy", mmap_mode="r") # numpy.memmap, ОС подгружает страницы по мере
print(m[1_000_000:1_000_010]) # прочитана одна страница, а не 4 ГБ
mmap_mode — способ работать с массивом больше оперативной памяти при случайном доступе:
операционная система сама решает, какие страницы держать. Для последовательного полного
прохода выигрыша нет, для выборочного чтения — огромный. И обязательно: np.load с
allow_pickle=True наследует все проблемы pickle, по умолчанию флаг выключен — не включайте
его без необходимости.
Когда pandas заканчивается
pandas однопоточный (за редкими исключениями), держит всё в памяти и требует примерно в 2–5 раз больше RAM, чем размер данных, потому что промежуточные результаты материализуются. Практический ориентир по объёму одного набора данных:
Ключевая идея polars и DuckDB — ленивое выполнение. Вы описываете план, оптимизатор его
переписывает, и только collect() запускает работу:
import polars as pl
import duckdb
# polars: план строится лениво, файл читается частично
result = (
pl.scan_parquet("big.parquet")
.filter(pl.col("day") == 5)
.group_by("city")
.agg(pl.col("amount").sum())
.collect()
) # 30 мс
# duckdb: тот же результат на SQL, прямо по файлу
duckdb.sql("""
select city, sum(amount)
from 'big.parquet'
where day = 5
group by city
""").df() # 34 мс
Для сравнения: pandas на уже загруженном в память кадре — 19 мс, но загрузка стоила 55 мс и 96 МиБ RAM. То есть polars и DuckDB выигрывают не «в разы на маленьких данных», а на данных, которые в память не помещаются или помещаются впритык, — там разница становится качественной, а не количественной.
Когда что выбирать честно:
- pandas — интерактивный анализ, зрелая экосистема (statsmodels, scikit-learn, seaborn), любой код из интернета работает. Ниже 1 ГБ альтернативы не нужны.
- polars — ETL-пайплайны, где важна скорость и предсказуемость памяти; строгая типизация выражений ловит ошибки на этапе построения плана. Экосистема тоньше, API другой.
- DuckDB — когда задача естественно формулируется на SQL, нужны джойны нескольких
больших файлов или требуется читать Parquet прямо из S3. Отлично сочетается с pandas:
duckdb.sql("select * from my_dataframe")работает без копирования. - Spark/Dask — когда данные реально не помещаются на одну машину. Порог входа высокий, и большинство задач, для которых берут Spark, решаются DuckDB на одном сервере с 128 ГБ.
Профилирование пайплайна данных
Отладка производительности здесь отличается от обычного профилирования CPU: узкое место чаще в памяти и копиях, чем в вычислениях.
# 1. Сколько занимает кадр НА САМОМ ДЕЛЕ
df.info(memory_usage="deep")
print(df.memory_usage(deep=True).sort_values(ascending=False).head())
# 2. Пиковая память шага — memray или tracemalloc
# python -m memray run script.py && python -m memray flamegraph memray-*.bin
# 3. Кто аллоцирует временные массивы — построчный профиль памяти
# pip install memory-profiler; @profile над функцией
# 4. Микробенчмарк альтернатив
# %timeit df.groupby("city")["amount"].agg("mean") в Jupyter
# python -m timeit -s "..." "..." в консоли
Практический алгоритм: сначала посмотрите на dtypes (одна колонка object часто съедает
больше, чем все остальные вместе), потом на количество копий (каждый .copy(),
reset_index(), sort_values() — это новый буфер), и только потом оптимизируйте
вычисления. Полный инструментарий профилирования — в
«Производительности» и в треке
«Измерение».
Честно про место этого стека
Где numpy/pandas выигрывают безоговорочно. Интерактивный анализ данных объёмом до нескольких гигабайт; научные вычисления, где библиотека уже написана (scipy, scikit-learn, statsmodels); подготовка признаков для ML (см. «Данные и признаки»); любая задача, где важнее время разработчика, чем время машины. Экосистема здесь не имеет конкурентов ни в одном языке: то, что в R или Julia требует поиска, в Python уже есть.
Где проигрывают. Однопоточность pandas при доступных 16 ядрах. Память в 2–5 раз больше данных. Отсутствие статических гарантий: имя колонки — строка, опечатка обнаруживается в рантайме, а mypy тут почти бессилен (см. «Типизацию» — Protocol и TypedDict помогают на границах, но не внутри DataFrame). API pandas огромен и содержит десяток способов сделать одно и то же с разной производительностью — это плохо масштабируется на команду.
Куда не стоит тащить. Не используйте pandas как слой доступа к данным в веб-приложении:
read_sql в обработчике запроса тянет весь результат в память и держит его до конца
запроса. Не используйте DataFrame как структуру для бизнес-логики — там нужны dataclass или
Pydantic-модели, см. «ООП» и следующую статью про API.
Не стройте на pandas потоковую обработку событий — это батчевый инструмент, для стриминга
берите нормальный движок. И не пишите на numpy то, что естественно ложится на SQL: база
данных с индексами почти всегда обгонит любой in-memory-джойн, и не потребует загружать
таблицу целиком («Индексы и планы запросов»).
Чеклист перед мержем data-кода
- Ни одного
iterrows;applyтолько там, где векторный аналог невозможен и это измерено. - Ни одного
pd.concatвнутри цикла. - У каждого
mergeестьvalidate=и проверка размера результата. -
read_csvвызывается сdtype=,usecols=и явнымdate_format. - Строковые колонки низкой кардинальности переведены в
category. - В коде нет цепочечного присваивания и
inplace=True. - Промежуточные данные лежат в Parquet, отсортированные по ключу фильтрации, а не в CSV.
-
pickleне пересекает границу процесса, версии или доверия. - Перед выражениями с
[:, None]посчитан размер результата. - Есть тест на пайплайн с эталонным маленьким набором данных — pytest и
pd.testing.assert_frame_equal, см. «Тестирование».
Мини-итог
Модель исполнения решает всё: numpy быстр не потому, что «C», а потому, что цикл
переехал в скомпилированный код и данные лежат непрерывно. Как только вы возвращаете цикл
в интерпретатор — через apply, iterrows, np.vectorize или object-колонку — вы
возвращаете и старую производительность, часто с трёх-четырёхкратным штрафом сверху.
Второй половиной задачи управляет память: срез бесплатен, маска — нет; broadcasting
бесплатен, а его результат — нет; concat в цикле копирует O(n²) байт. Форматы — это
продолжение той же логики на диск: колоночное хранение позволяет не читать то, что не нужно,
и pushdown работает ровно настолько, насколько вы позаботились о порядке данных.
Источники
- NumPy user guide — начните с «NumPy fundamentals» и «Broadcasting»; раздел про внутреннее устройство объясняет strides.
- NumPy 2.0 migration guide и NEP 50 — новые правила приведения типов.
- pandas User Guide, особенно Copy-on-Write, Scaling to large datasets и Enhancing performance.
- Wes McKinney, Python for Data Analysis, 3rd ed. — доступна бесплатно онлайн, написана автором pandas.
- Jake VanderPlas, Python Data Science Handbook — лучшее объяснение broadcasting и индексирования.
- Apache Parquet format — спецификация row group, column chunk и страничной статистики.
- Apache Arrow Python docs — Arrow как формат памяти, интеграция с pandas и Parquet.
- polars User Guide — раздел про lazy API и оптимизации плана.
- DuckDB documentation — SQL по файлам Parquet и интеграция с Python.
- Stefan van der Walt et al., The NumPy array: a structure for efficient numerical computation — академическая статья о модели памяти numpy.
- pickle security warning — читать перед каждым соблазном сохранить объект «как есть».
Что дальше
Веб и API: FastAPI, Django, валидация, асинхронные приложения — разберём, как Python отдаёт данные наружу: WSGI против ASGI, Pydantic как граница типов, FastAPI и Django ORM, и почему тяжёлые вычисления над DataFrame нельзя делать прямо в обработчике HTTP-запроса.