Python Работа с данными: numpy, pandas, форматы, типичные ошибки производительности
0%

Работа с данными: numpy, pandas, форматы, типичные ошибки производительности

Работа с данными: 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 устроен противоположным образом: один непрерывный буфер однотипных значений плюс маленький заголовок с метаданными.

Анатомия ndarray: заголовок, буфер, view против копии

Из этой картинки следуют все свойства 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, скорее всего пора менять формат или инструмент — об этом ниже.

Форматы: чем вы платите за каждый

Строчная и колоночная раскладка, row group и отсечение по статистике

Один и тот же набор данных (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 работает ровно настолько, насколько вы позаботились о порядке данных.

Источники

Что дальше

Веб и API: FastAPI, Django, валидация, асинхронные приложения — разберём, как Python отдаёт данные наружу: WSGI против ASGI, Pydantic как граница типов, FastAPI и Django ORM, и почему тяжёлые вычисления над DataFrame нельзя делать прямо в обработчике HTTP-запроса.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов