Присваивание срезу не доходит до исходной таблицы
В выгрузке заказов аналитик отбирает отменённые записи, присваивает срезу нулевую выручку, строит сводку и видит прежние суммы. Код отрабатывает без падения, но исходный датафрейм не меняется, и без сверки с исходником ошибку легко пропустить.
Фильтр по булевой маске возвращает новый объект, и присваивание меняет его, а не df. В pandas 2.x без Copy-on-Write такая запись вызывает предупреждение SettingWithCopyWarning: pandas не знает, представление перед ним или копия, и предупреждает, что изменение может потеряться. В 2.x режим Copy-on-Write включается вручную (pd.options.mode.copy_on_write = True), в pandas 3.0 он работает по умолчанию: любой срез ведёт себя как копия, SettingWithCopyWarning убран, и такая запись молча меняет только cancelled. Цепочка в одну строку, df[mask]["revenue"] = 0, в этом режиме исходник не меняет никогда, а pandas выдаёт предупреждение ChainedAssignmentError. Все числа в примерах условны и показывают только механику.
Ошибка:
cancelled = df[df["status"].eq("cancelled")]
cancelled["revenue"] = 0
Исправление: указать строки и столбец одной операцией через .loc.
mask = df["status"].eq("cancelled")
df.loc[mask, "revenue"] = 0
.loc меняет revenue прямо в df и одинаково работает в pandas 2.x и 3.0. Не глушите предупреждение через pd.options.mode.chained_assignment = None: оно указывает ровно на то место, где расчёт отделился от исходных данных.
Независимый срез требует явной копии
Иногда исходную таблицу менять не нужно: например, VIP-сегмент выделяют для отдельной модели или другого модуля. Тогда .copy() нужен не для того, чтобы заглушить предупреждение: он явно отделяет новый объект от исходного. Без него в pandas 2.x поведение кода зависит от того, вернул pandas представление или копию.
Ошибка:
vip = df[df["segment"].eq("vip")]
vip["needs_review"] = True
Исправление:
vip = df.loc[df["segment"].eq("vip"), ["user_id", "revenue"]].copy()
vip["needs_review"] = True
Не копируйте весь датафрейм без нужды: на крупной таблице это удваивает расход памяти, поэтому в примере копируются только два нужных столбца. .loc нужен для правки исходника, .copy() для объекта, который дальше живёт отдельно. На когортах цена ошибки выше: даже аккуратная проверка связи активации и удержания теряет смысл, если сегмент собран из случайно изменённых строк.
Цикл по строкам вместо векторизации
iterrows() кажется понятным, потому что напоминает обычный цикл. Но каждая итерация создаёт Series, а запись через .loc снова ищет ячейку. На десятках строк разница незаметна, на большой выгрузке простая арифметика становится узким местом.
Ошибка с iterrows():
for idx, row in df.iterrows():
df.loc[idx, "net"] = row["gross"] - row["fee"]
Исправление для арифметики по столбцам:
df["net"] = df["gross"] - df["fee"]
Векторизованная операция обрабатывает весь столбец сразу, сохраняет типы и читается как формула. Цикл уместен для внешнего API, файловой очереди или логики, невыразимой операциями над сериями. Для расчётов внутри датафрейма сначала проверьте методы .str, .dt, .where, .clip, .shift или групповую агрегацию.
Похожая ловушка скрыта в apply(axis=1): он удобнее цикла, но вызывает Python-функцию для каждой строки. Ошибка:
df["tier"] = df.apply(
lambda row: "high" if row["revenue"] >= 100 else "base",
axis=1,
)
Исправление:
import numpy as np
df["tier"] = np.where(df["revenue"].ge(100), "high", "base")
apply не запрещён: он нужен для логики, которую нельзя записать векторными методами, но не должен быть выбором по привычке. Если условие зависит от трёх столбцов, маска с &, | и скобками почти всегда прозрачнее для проверки.
Почему inplace=True не экономит работу
От inplace=True часто ждут экономии памяти и скорости. На деле флаг лишь просит метод изменить объект на месте, и метод возвращает None. Многие операции всё равно строят новые данные внутри и затем подменяют ими старые, так что выигрыш, если он вообще есть, виден только при профилировании.
Ошибка:
clean = df.dropna(inplace=True)
print(clean.shape)
Исправление простое, результат присваивают явно:
clean = df.dropna()
print(clean.shape)
Явное присваивание удобно в пайплайне: видно, какая версия таблицы идёт дальше, а исходник остаётся для сверки. inplace=True допустим для короткой локальной правки, если результат не присваивают. Смешивать стили в одном ноутбуке опасно: переменная внезапно становится None, а ошибка проявляется в следующей ячейке.
Какие типы на самом деле вернул read_csv
CSV не хранит полноценную схему. Идентификатор с ведущими нулями может стать числом, сумма с десятичной запятой окажется строкой, а дата получит тип object. Ошибка проявляется поздно: сортировка идёт лексикографически, среднее не считается, а соединение по user_id не находит совпадений.
Ошибка:
df = pd.read_csv("orders.csv")
df["amount"].mean()
df.sort_values("order_date")
Схему лучше задать на входе. В примере идентификатор читается как строка, чтобы сохранить ведущие нули, сумма с десятичной запятой тоже читается как строка и затем явно переводится в число, а дата в формате ISO разбирается сразу:
df = pd.read_csv(
"orders.csv",
parse_dates=["order_date"],
dtype={"user_id": "string", "amount": "string"},
)
df["amount"] = pd.to_numeric(
df["amount"].str.replace(",", ".", regex=False),
errors="raise",
)
Если дата записана не в ISO, например 05.01.2024, передайте date_format="%d.%m.%Y": параметр появился в pandas 2.0, а infer_datetime_format и date_parser с той же версии считаются устаревшими. Когда в файле разделитель ;, а десятичный знак запятая, проще сразу указать sep=";", decimal=",".
errors="raise" на этапе контроля качества покажет первое значение вне договорённого формата. errors="coerce" превращает сбойные значения в пропуски, но их нужно отдельно посчитать и разобрать, иначе потеря данных останется незаметной. Перед прогнозом грязные поля способны испортить и мониторинг дрейфа модели: распределение признака меняется из-за парсинга, а не поведения пользователей.
NaN не сравнивается через равно
Пропуск NaN не равен даже самому себе. Сравнение == np.nan даёт False в каждой строке, поэтому фильтр пустеет, хотя пропуски есть. Это свойство представления отсутствующего числового значения, а не ошибка pandas.
Ошибка:
import numpy as np
missing = df[df["country"] == np.nan]
Исправление:
missing = df[df["country"].isna()]
filled = df[df["country"].notna()]
Обратная ловушка спрятана в !=: df["country"].ne("DE") вернёт True для пропусков, и строки без страны попадут в выборку «не Германия». Если им там не место, добавьте условие явно: df["country"].ne("DE") & df["country"].notna(). Не подменяйте пропуски нулём или строкой "unknown", пока не выяснен их смысл: отсутствие платежа, неизвестная страна и нулевая сумма означают разные состояния.
Merge и groupby меняют форму данных
merge способен увеличить число строк без единой ошибки. Если в обеих таблицах на один ключ приходится несколько записей, соединение создаёт все пары совпадений. Когда в заказах один user_id встречается дважды и в справочнике клиентов тоже дважды, после соединения появятся четыре строки для пользователя, а выручка искусственно вырастет.
Ошибка:
result = orders.merge(users, on="user_id", how="left")
Исправление начинается с контракта на кардинальность. Если users обязан содержать одну актуальную строку на пользователя, закрепите это параметром validate: при нарушении pandas выбросит MergeError, а не размножит строки молча.
users_one = (
users.sort_values("updated_at")
.drop_duplicates("user_id", keep="last")
)
result = orders.merge(
users_one,
on="user_id",
how="left",
validate="many_to_one",
)
Удалять дубликаты можно, только если спецификация источника разрешает выбирать последнюю запись и updated_at уже приведён к дате. Если история клиента нужна целиком, нужен другой ключ или отдельная логика выбора версии. При A/B-тестировании моделей в продакшене размноженные строки бьют сильнее: пользователь с дублем получает двойной вес, и сравнение групп смещается.
groupby создаёт индекс из группировочных полей. Это нормально для вывода, но мешает, если результат нужно соединять, выгружать или передавать функции, ожидающей обычные столбцы.
Ошибка:
summary = events.groupby("channel")["revenue"].sum()
summary.merge(targets, on="channel")
Исправление:
summary = (
events.groupby("channel", as_index=False)
.agg(revenue=("revenue", "sum"))
)
summary = summary.merge(targets, on="channel", validate="one_to_one")
as_index=False обычно проще позднего reset_index(), если агрегат останется таблицей. Перед merge после группировки проверьте число строк и уникальность ключа: после агрегации одна строка соответствует уже не событию, а каналу.
Большой CSV: читать только нужное
Память уходит не только на сам файл: в ней живут столбцы датафрейма, временные объекты при очистке, копии после фильтрации и промежуточные результаты merge. Читать файл целиком ради двух полей значит занять память тем, что выбросится следующей строкой кода.
Ошибка:
events = pd.read_csv("events.csv")
events = events[["user_id", "event"]]
Правильнее отобрать поля ещё при чтении, назначить типы и агрегировать частями. Размер чанка ниже условный: его подбирают под доступную память и стоимость последующей обработки.
totals = None
for chunk in pd.read_csv(
"events.csv",
usecols=["event"],
dtype={"event": "category"},
chunksize=100_000,
):
part = chunk["event"].value_counts()
totals = part if totals is None else totals.add(part, fill_value=0)
totals = totals.astype("int64") # add с fill_value возвращает float
Категориальный тип полезен для поля с небольшим устойчивым набором значений, например имени события. Для почти уникального идентификатора он может добавить накладные расходы вместо экономии. Если чанки не нужны, чтение ускоряет engine="pyarrow" (требует установленного пакета pyarrow), а dtype_backend="pyarrow" хранит столбцы в типах Arrow: строки в них обычно занимают заметно меньше памяти, чем в object. Ограничение одно, но важное: движок pyarrow не поддерживает chunksize. Если анализ постоянно требует широкий файл, лучше пересмотреть формат хранения, например перейти на Parquet, а не лечить каждую загрузку случайными astype и drop.
От ноутбука к пайплайну: контракты данных
Когда ноутбук превращается в регулярный пайплайн, вопрос меняется: не «какую функцию pandas вызвать», а «какой результат обязана вернуть операция». Для каждого источника полезно зафиксировать типы, гранулярность строк, допустимые пропуски и кардинальность соединений. Тогда .loc, .copy(), validate и чтение чанками становятся не набором приёмов, а проверяемыми правилами.
Перед переходом на pandas 3.0 прогоните пайплайн на 2.2 или 2.3 с pd.options.mode.copy_on_write = "warn": pandas покажет места, где код рассчитывает, что изменение среза дойдёт до исходной таблицы. После каждого шага сверяйте три вещи: df.shape, df.dtypes и уникальность ключа (df["user_id"].is_unique). Каждая проверка занимает одну строку и ловит ошибки из этой статьи до того, как цифры попадут в дашборд.