Пропуски и дубликаты

Реальные данные всегда содержат дыры и повторы — от того, как вы их обработаете, напрямую зависит корректность анализа.

Пропуск — отсутствующее значение (NaN для float, NaT для дат, pd.NA для nullable-типов). Дубликат — строка, полностью или частично совпадающая с другой.

Как pandas обозначает пропуски

Маркер пропуска зависит от типа столбца: для обычных числовых — numpy.nan (он же тянет тип в float), для дат — NaT, а для nullable-типов (Int64, boolean, string) — единый pd.NA, который сохраняет исходный тип. Главное свойство любого пропуска: он не равен ничему, даже самому себе. Поэтому NaN == NaN даёт False, и проверять пропуски сравнением нельзя — для этого есть isna().

import pandas as pd, numpy as np

df.isna()       # таблица True/False: где пропуски
df.isna().sum() # сколько пропусков в каждом столбце — главный отчёт
df.notna()      # обратное: где значения есть

df.isna().sum() — первое, что стоит сделать с новыми данными: он мгновенно показывает, какие столбцы дырявые и насколько.

Важно различать почему данные пропущены — это влияет на выбор стратегии. Пропуск может означать «значение не измерили» (датчик не сработал), «значение неприменимо» (дата увольнения у работающего сотрудника) или «значение равно нулю, но записано как пусто». Это три разные ситуации: в первой уместна оценка/интерполяция, во второй пропуск осмыслен и удалять строку нельзя, в третьей нужно явно заполнить нулём. Механически «убрать все NaN» — частая ошибка новичков, которая искажает данные. Сначала поймите природу дыр, потом выбирайте инструмент.

Три стратегии: удалить, заполнить, интерполировать

Универсального решения нет — выбор зависит от того, почему данные пропущены и что вы будете считать дальше.

dropna: удалить строки или столбцы с пропусками

df.dropna()                       # убрать строки, где есть хоть один NaN
df.dropna(subset=["цена"])        # убрать строки, где NaN именно в 'цена'
df.dropna(axis=1)                 # убрать СТОЛБЦЫ с пропусками
df.dropna(thresh=3)               # оставить строки, где ≥ 3 непустых значений

Удаление уместно, когда пропусков мало и они «случайны». Опасно, если пропусков много: можно выбросить половину данных. subset ограничивает проверку нужными столбцами — обычно так и надо.

fillna: заполнить значением

df["цена"] = df["цена"].fillna(0)                    # константой
df["цена"] = df["цена"].fillna(df["цена"].median())  # медианой
df["товар"] = df["товар"].fillna("неизвестно")        # для строк
df["x"] = df["x"].ffill()   # протянуть предыдущее значение вперёд
df["x"] = df["x"].bfill()   # протянуть следующее значение назад

Чем заполнять — содержательный вопрос. Медиана устойчивее среднего к выбросам. Для временных рядов часто логичен ffill (последнее известное значение «держится» до следующего). Заполнять нулём цену — почти всегда ошибка: ноль исказит средние и суммы.

interpolate: восстановить по соседям

Для упорядоченных числовых данных (особенно временных рядов) пропуски можно оценить интерполяцией — по линии между соседними известными точками:

values = [10, None, None, 40, 50]

# линейная интерполяция: заполняем дыры по прямой между известными точками
result = list(values)
i = 0
while i < len(result):
    if result[i] is None:
        start = i - 1
        end = i
        while end < len(result) and result[end] is None:
            end += 1
        left, right = result[start], result[end]
        steps = end - start
        for k in range(start + 1, end):
            result[k] = left + (right - left) * (k - start) / steps
        i = end
    else:
        i += 1

print(result)

Вывод:

[10, 20.0, 30.0, 40, 50]

Ровно это делает df["x"].interpolate(): две дыры между 10 и 40 заполнились значениями 20 и 30 по прямой. Метод подходит, когда между точками есть осмысленная непрерывность (температура, цена по времени), и не подходит для категорий.

Дубликаты: найти и удалить

Повторяющиеся строки появляются из-за двойной загрузки, ошибок join или экспорта. Их ищут через duplicated() (булева маска: True у повторов) и удаляют через drop_duplicates().

df.duplicated()                  # True для строк-повторов (кроме первой)
df.duplicated().sum()            # сколько дубликатов всего
df.drop_duplicates()             # оставить уникальные строки
df.drop_duplicates(subset=["email"])            # уникальность по email
df.drop_duplicates(subset=["email"], keep="last")  # оставить последний

Параметр subset критичен: «дубликат по email» и «дубликат по всем столбцам» — разные вещи. keep решает, какую из повторяющихся строк сохранить: первую ("first", по умолчанию), последнюю ("last") или ни одной (False — удалить все повторы целиком).

Логику легко повторить на словаре «виденных» ключей:

rows = [
    {"email": "[email protected]", "имя": "Аня"},
    {"email": "[email protected]", "имя": "Боря"},
    {"email": "[email protected]", "имя": "Аня (повтор)"},
]

seen = set()
unique = []
for r in rows:
    if r["email"] not in seen:   # keep="first": берём первое появление
        seen.add(r["email"])
        unique.append(r)

for r in unique:
    print(r["email"], r["имя"])

Вывод:

[email protected] Аня
[email protected] Боря

Множество seen запоминает уже встреченные ключи — так drop_duplicates(subset=["email"], keep="first") и отбрасывает второе появление [email protected].

Перед удалением дубликатов почти всегда нужна нормализация, иначе «почти одинаковые» строки проскользнут. "[email protected]" и "[email protected] " для drop_duplicates — разные значения, хотя для человека это один email. Поэтому типичный пайплайн такой: сначала привести ключ к каноничному виду (df["email"] = df["email"].str.strip().str.lower()), и только потом искать дубликаты. То же касается чисел-строк с разными разделителями и дат в разных форматах. Дедупликация без предварительной нормализации даёт ложное чувство чистоты.

Подводные камни

  • Сравнение с NaN всегда False. Проверяйте пропуски через isna(), а не == np.nan.
  • fillna(0) для денег и метрик. Ноль — это значение, а не «нет данных»; он испортит средние, суммы и доли. Думайте, что значит пропуск.
  • inplace и Copy-on-Write. При CoW df["x"].fillna(0, inplace=True) не изменит df; переприсваивайте: df["x"] = df["x"].fillna(0).
  • drop_duplicates без subset требует совпадения по всем столбцам; «почти одинаковые» строки (разный регистр, пробелы) он не поймает — сперва нормализуйте.

Лучшие практики

  • Начинайте с диагностики: df.isna().sum() и df.duplicated().sum().
  • Выбирайте стратегию пропусков осознанно: удалять, заполнять осмысленным значением или интерполировать — в зависимости от природы данных.
  • Для дубликатов всегда уточняйте subset и keep — это и есть бизнес-логика «что считать повтором».
  • Перед поиском дубликатов нормализуйте строки (регистр, пробелы), иначе часть повторов проскользнёт.

Итог

  • Пропуски: NaN/NaT/pd.NA; ищите через isna(), считайте через isna().sum().
  • Три стратегии: dropna, fillna/ffill/bfill, interpolate.
  • Дубликаты: duplicated() находит, drop_duplicates(subset, keep) удаляет.
  • Главное — содержательный выбор стратегии, а не механическое заполнение нулём.
Проверьте себя
1. Как правильно посчитать число пропусков в каждом столбце?
Adf == np.nan
Bdf.isna().sum()
Cdf.count()
Ddf.duplicated()
2. Почему заполнять пропуски в столбце 'цена' нулём через fillna(0) обычно опасно?
Afillna не работает с числами
BНоль — это реальное значение и исказит средние, суммы и доли
CНоль нельзя присвоить в pandas
DЭто вызовет SettingWithCopyWarning
3. Что делает drop_duplicates(subset=['email'], keep='last')?
AУдаляет все строки с повторяющимся email
BОставляет по каждому email последнюю встретившуюся строку
CОставляет первую строку по каждому email
DУдаляет столбец email
Поддержать проект