Очистка данных в Pandas: пропуски NaN, дубликаты и типы
Настоящая грязная выгрузка из CRM: пропуски NaN, дубликаты и цены-строки. Чистим её isna, dropna, fillna, drop_duplicates и astype — шаг за шагом.
Редакция Питоники
Коллега из отдела продаж прислала выгрузку заказов: «Файл рабочий, только аккуратнее там». Вы открываете его в pandas и видите: пустые клетки, один и тот же заказ дважды, а в столбце с ценой — текст «нет данных». В Excel вы бы просмотрели строки глазами и правили руками до вечера. В pandas на всё уходит один скрипт из шести шагов. Этим и займёмся.
Очистка данных в pandas — навык, который отделяет аналитика от человека, запускающего чужие скрипты. По грубой прикидке, на неё уходит больше половины рабочего времени: пропуски NaN, дубликаты и перепутанные типы встречаются почти в каждой выгрузке. В уроке про фильтрацию мы договорились, что фильтры работают только на честных данных. Теперь наведём этот порядок.
Выгрузка из CRM: встречаем грязные данные
Браузерный интерпретатор не видит файлов, поэтому выгрузку зашьём прямо в код как текст и скормим его read_csv через io.StringIO — источник будет строкой, а не файлом. Для read_csv это неважно: методу всё равно, откуда пришли байты. Если пропустили разбор его параметров, вернитесь к уроку про чтение данных.
import io
import pandas as pd
raw = """order_id,city,product,price,qty
1001,Москва,Ноутбук,54990,1
1002,Казань,Смартфон,24990,2
1003,Москва,Планшет,,1
1004,Сочи,Наушники,8990,3
1004,Сочи,Наушники,8990,3
1005,Казань,Ноутбук,57990,1
1006,Москва,Смартфон,25990,1
1007,,Планшет,31990,2
1008,Москва,Ноутбук,нет данных,1
"""
df = pd.read_csv(io.StringIO(raw))
print(df)
order_id city product price qty 0 1001 Москва Ноутбук 54990 1 1 1002 Казань Смартфон 24990 2 2 1003 Москва Планшет NaN 1 3 1004 Сочи Наушники 8990 3 4 1004 Сочи Наушники 8990 3 5 1005 Казань Ноутбук 57990 1 6 1006 Москва Смартфон 25990 1 7 1007 NaN Планшет 31990 2 8 1008 Москва Ноутбук нет данных 1
Девять строк, и в них уже весь джентльменский набор проблем. Пройдитесь по таблице взглядом и найдите сами, прежде чем читать список:
- у заказа 1003 пустая цена — pandas поставил туда NaN;
- у заказа 1007 нет города — тоже NaN;
- заказ 1004 встречается дважды, строка-двойник буквально совпадает посимвольно;
- у заказа 1008 вместо цены текст «нет данных» — маркер пропуска, который пишет человек, а не программа.
Заметьте: read_csv сам распознал две пустые ячейки как пропуски и подставил NaN. А вот «нет данных» он читать не обязан — для него это обычный текст. С этим разберёмся отдельно.
info(): таблица признаётся в проблемах
Первый диагноз ставится методом info() из первого урока. Он показывает число строк, сколько в каждом столбце непустых значений и какие у столбцов типы:
df.info()
<class 'pandas.core.frame.DataFrame'> RangeIndex: 9 entries, 0 to 8 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 order_id 9 non-null int64 1 city 8 non-null object 2 product 9 non-null object 3 price 8 non-null object 4 qty 9 non-null int64 dtypes: int64(2), object(3) memory usage: 492.0+ bytes
Два сигнала тревоги. Первый: у city и price по 8 non-null при девяти строках — где-то по одному пропуску. Второй, более коварный: price имеет тип object, то есть текст. Цена обязана быть числом int64, как order_id и qty. Столбец стал текстом ровно из-за «нет данных»: pandas видит в столбце хотя бы одну строку — и объявляет весь столбец текстовым, даже если остальные восемь значений идеально числовые. Это, кстати, причина номер один, почему df["price"].mean() у новичков падает с ошибкой.
isna: как найти пропуски NaN
NaN — это специальное значение «нет данных» из NumPy, плавающее как float. Из третьего урока вы помните главный подвох: любое сравнение с NaN ложно, поэтому маска df["price"] == NaN не сработает никогда. Для поиска пропусков есть честный метод isna — он возвращает True именно там, где значение отсутствует:
print(df.isna().sum()) # число пропусков в каждом столбце
print(df[df["city"].isna()]) # строки без города
print(df[df["price"].isna()]) # строки без цены
order_id 0 city 1 product 0 price 1 qty 0 dtype: int64 order_id city product price qty 7 1007 NaN Планшет 31990 2 order_id city product price qty 2 1003 Москва Планшет NaN 1
df.isna().sum() — команда, которую я вызываю на любом новом датасете раньше describe(). Читается она изнутри: isna() строит таблицу из True и False, sum() считает True как единицу по каждому столбцу. У нас один пропуск в city и один в price. Но постойте — мы же видели две ячейки с проблемами в price! Вторая, «нет данных», не посчитана: это строка, и pandas не знает, что она означает отсутствие значения. Каждый источник пропусков придётся объявлять вручную.
replace: превращаем «нет данных» в честный NaN
У каждой системы свои маркеры пустоты: «нет данных», «-», «n/a», пустая строка, ноль вместо «неизвестно». Стандартная чистка начинается с того, что все эти варианты сводятся к единому NaN — тогда с ними можно работать одними методами:
import numpy as np
df = df.replace("нет данных", np.nan)
print(df.isna().sum()) # пересчитываем пропуски
print(df["price"].dtype) # тип пока не изменился
order_id 0 city 1 product 0 price 2 qty 0 dtype: int64 object
Теперь price честно показывает два пропуска. Обратите внимание на dtype: он остался object — replace заменяет значения, но не выводит столбец в числовой тип. Кстати, для replace важно было написать именно np.nan, а не слово None: в числовых контекстах pandas работает с NaN, и все методы — isna, dropna, fillna — понимают его одинаково. За самим NaN и nan-функциями вроде np.nanmean, которые считают среднее поверх пропусков, стоит NumPy — мы разбирали их в уроке про агрегирующие функции NumPy. Импорт numpy нужен один раз в начале скрипта: import numpy as np.
Типы данных: почему price — это object
Логичный порыв — исправить тип вызовом astype("int64"). Давайте честно посмотрим, что будет:
df["price"].astype("int64")
pandas.errors.IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer
Ошибка честная: целое число не может быть «отсутствующим», NaN живёт только в float. Порядок исправления типов всегда один: сначала привести столбец к float, позволив NaN, — для этого есть pd.to_numeric с параметром errors="coerce":
df["price"] = pd.to_numeric(df["price"], errors="coerce")
print(df["price"].dtype)
print(df["price"])
float64 0 54990.0 1 24990.0 2 NaN 3 8990.0 4 8990.0 5 57990.0 6 25990.0 7 31990.0 8 NaN Name: price, dtype: float64
Столбец стал float64, и все значения — настоящие числа. Параметр errors="coerce" означает «принуждай»: любое значение, которое нельзя превратить в число, становится NaN вместо ошибки. Это страховка на будущее — если в файле попадётся ещё одна кривая ячейка, скрипт не упадёт, а соберёт пропуск в ту же копилку. Альтернатива, errors="raise", полезна наоборот: когда текст в числовом столбце — сигнал аварии, и вы хотите об этом узнать громко.
dropna: удалить строки с пропусками
Самое простое решение проблемы пропусков — выкинуть строки. Метод dropna удаляет все строки, где есть хотя бы один NaN. Важно знать заранее: он не меняет таблицу на месте, а возвращает новую. Если написать df.dropna() и не присвоить результат — вы просто посмотрите на пустую операцию:
print(df.dropna()) # без строк с любым NaN
print(len(df.dropna(subset=["city"]))) # удаляем только по городу
order_id city product price qty 0 1001 Москва Ноутбук 54990.0 1 1 1002 Казань Смартфон 24990.0 2 3 1004 Сочи Наушники 8990.0 3 4 1004 Сочи Наушники 8990.0 3 5 1005 Казань Ноутбук 57990.0 1 6 1006 Москва Смартфон 25990.0 1 8
Из девяти строк осталось шесть: ушли заказы 1003 и 1008 с пустой ценой и 1007 с пустым городом. Параметр subset=["city"] сужает приговор: удалять только там, где пропущен город, — остаётся восемь строк. Есть и how="all" для строк, пустых целиком, и thresh=N для «оставь строки, где заполнено хотя бы N полей». Когда удалять безопасно: пропусков мало (скажем, до одного-двух процентов) и потеря строк не смещает выводы. Когда опасно: пропуски сосредоточены в одном сегменте — например, все заказы без города оказались заказами нового региона, и вы выкинули весь регион, даже не заметив.
fillna: заполнить пропуски осмысленно
Чаще пропуски не удаляют, а заполняют. Правило одно: значение-заменитель должно быть осмысленным для столбца. Городу подходит метка «неизвестен» — это честная категория, которую видно в отчётах. Цене подходит медиана: устойчивая к выбросам середина отсортированных значений, которую мы обсуждали в первом уроке про Series и DataFrame — там средняя цена 34 157 была выше медианы 28 990 именно из-за дорогих ноутбуков.
df["city"] = df["city"].fillna("неизвестен")
median_price = df["price"].median()
print("Медиана цены:", median_price)
df["price"] = df["price"].fillna(median_price)
print(df)
Медиана цены: 25990.0 order_id city product price qty 0 1001 Москва Ноутбук 54990.0 1 1 1002 Казань Смартфон 24990.0 2 2 1003 Москва Планшет 25990.0 1 3 1004 Сочи Наушники 8990.0 3 4 1004 Сочи Наушники 8990.0 3 5 1005 Казань Ноутбук 57990.0 1 6 1006 Москва Смартфон 25990.0 1 7 1007 неизвестен Планшет 31990.0 2 8 1008 Москва Ноутбук 25990.0 1
Медиана по известным ценам — 25 990 рублей, и оба заказа с пропуском получили её. Это допущение, и мы в нём честно признались: в данных появился фиктивный и одинаковый уровень цены. Таблица заполнилась, NaN не осталось, типы почти готовы. Осталось закрепить правило выбора заменителя:
| Ситуация | Что делать | Чем заменить |
|---|---|---|
| пропусков мало, строки независимы | dropna() | не нужна |
| категориальный столбец | fillna() | «неизвестен», мода (самое частое значение) |
| числовой столбец с выбросами | fillna() | медиана |
| числовой столбец без выбросов | fillna() | среднее |
| нулевая цена — легальный сценарий | fillna(0) | только если правда «нет = ноль» |
astype: приводим столбец к правильному типу
Пропусков больше нет — значит, злополучная ошибка из прошлого раздела больше не грозит, и цену можно перевести в целые числа:
df["price"] = df["price"].astype("int64")
print(df.dtypes)
order_id int64 city object product object price int64 qty int64 dtype: object
Техпаспорт вернулся к норме: два числовых столбца int64, два текстовых object. astype умеет приводить в обе стороны: astype("float64") для дробных, astype(str) для текста. Одна деталь, о которой молчат туториалы: astype отбрасывает дробную часть молча, без округления — 25990.7 превратится в 25990, а не в 25991. Если данные дробные по смыслу, сначала примените round(), потом astype; если цены копеечные — лучше оставить float64 и не терять копейки вовсе.
Дубликаты: duplicated и drop_duplicates
Последний грязный слой — строки-двойники. Дубликат заказа — это двойной счёт выручки: два раза по 8990 рублей в отчёте, хотя покупка была одна. Метод duplicated помечает повторы: True получает каждая строка, совпадающая с предыдущей, — первое вхождение по умолчанию считается оригиналом:
Как найти и удалить дубликаты в pandas?
print(df.duplicated().sum()) # сколько дубликатов
print(df[df.duplicated(keep=False)]) # показать оба вхождения
df = df.drop_duplicates() # удалить повторы
print(df)
1 order_id city product price qty 3 1004 Сочи Наушники 8990 3 4 1004 Сочи Наушники 8990 3 order_id city product price qty 0 1001 Москва Ноутбук 54990 1 1 1002 Казань Смартфон 24990 2 2 1003 Москва Планшет 25990 1 3 1004 Сочи Наушники 8990 3 5 1005 Казань Ноутбук 57990 1 6 1006 Москва Смартфон 25990 1 7 1007 неизвестен Планшет 31990 2 8 1008 Москва Ноутбук 25990 1
Один дубликат найден, оба вхождения показаны, после drop_duplicates осталось восемь строк. Параметр keep="last" оставит последнее вхождение вместо первого, а keep=False пометит оба. Полезнее всего параметр subset: df.drop_duplicates(subset=["order_id"]) удаляет повторяющиеся номера заказов, даже если остальные поля различаются — а так бывает в реальных выгрузках, когда заказ выгружен дважды с разными статусами. Полные совпадения строк редки; дубли по ключу — массовое явление.
Кейс: чистая выгрузка от и до
Соберём весь конвейер в один блок. Порядок шагов здесь не косметика, а смысл: replace — раньше to_numeric, потому что to_numeric должен увидеть уже чистый столбец; astype — после fillna, потому что int не умеет NaN; drop_duplicates — последним, чтобы сравнивать уже приведённые к одному формату строки.
import io
import numpy as np
import pandas as pd
raw = """order_id,city,product,price,qty
1001,Москва,Ноутбук,54990,1
1002,Казань,Смартфон,24990,2
1003,Москва,Планшет,,1
1004,Сочи,Наушники,8990,3
1004,Сочи,Наушники,8990,3
1005,Казань,Ноутбук,57990,1
1006,Москва,Смартфон,25990,1
1007,,Планшет,31990,2
1008,Москва,Ноутбук,нет данных,1
"""
df = pd.read_csv(io.StringIO(raw))
# 1. Скрытые маркеры пропусков превращаем в NaN
df = df.replace("нет данных", np.nan)
# 2. Цены в числа: кривые значения станут NaN, а не ошибкой
df["price"] = pd.to_numeric(df["price"], errors="coerce")
# 3. Город без значения честно помечаем
df["city"] = df["city"].fillna("неизвестен")
# 4. Цену заполняем медианой, не нулём
df["price"] = df["price"].fillna(df["price"].median())
# 5. Теперь можно и в целые
df["price"] = df["price"].astype("int64")
# 6. Дубликаты удаляем, индекс пересобираем
df = df.drop_duplicates().reset_index(drop=True)
print(df)
df.info()
order_id city product price qty 0 1001 Москва Ноутбук 54990 1 1 1002 Казань Смартфон 24990 2 2 1003 Москва Планшет 25990 1 3 1004 Сочи Наушники 8990 3 4 1005 Казань Ноутбук 57990 1 5 1006 Москва Смартфон 25990 1 6 1007 неизвестен Планшет 31990 2 7 1008 Москва Ноутбук 25990 1 <class 'pandas.core.frame.DataFrame'> RangeIndex: 8 entries, 0 to 7 Data columns (total 5 columns): # Column Non-Null Count Dtype --- ------ -------------- ----- 0 order_id 8 non-null int64 1 city 8 non-null object 2 product 8 non-null object 3 price 8 non-null int64 4 qty 8 non-null int64 dtypes: int64(3), object(2) memory usage: 452.0+ bytes
Девять грязных строк вошли, восемь чистых вышли: все пропуски обработаны, цена — int64, двойников нет, индекс сплошной. Такой скрипт живёт в репозитории и выполняется за секунды каждый раз, когда придёт новая выгрузка. Но присмотритесь к заказу 1008: Ноутбук с ценой 25 990 — это же медиана по всей таблице, её тащат наушники и смартфоны. Заполнять цену товара общей медианой — грубое допущение. Честнее считать медиану по группе товаров: у ноутбуков своя, у наушников своя. Групповые операции — ровно то, чем займёмся в уроке про groupby.
Чек-лист очистки данных
Сожмём урок в рабочий ритуал. Прогоняйте его по памяти на каждом новом датасете — в этом порядке:
- info() — сколько строк, где неполные столбцы, какие типы;
- isna().sum() — точная карта пропусков по столбцам;
- replace — маркеры вроде «нет данных» и «-» превратить в NaN;
- pd.to_numeric(errors="coerce") — числовые столбцы, приехавшие как object;
- drop_duplicates — дубликаты целиком и по ключу через subset;
- dropna или fillna — решить для каждого столбца, с медианой для чисел и «неизвестен» для категорий;
- astype — привести типы к финальному виду, когда NaN не осталось;
- describe() и isna().sum() снова — убедиться, что таблица чиста.
Пять минут ритуала против часов отладки отчёта, который считает дубликат дважды и не видит пустую цену. Прибыльный обмен, проверено.
Что дальше: группировка groupby
Данные чисты: пропусков нет, типы правильные, двойники удалены. Пора на них зарабатывать — считать выручку по городам, средний чек по товарам, число заказов по дням. Инструмент для всего этого один — groupby, «сводная таблица Excel, только на коде», и он достаточно ёмкий, чтобы получить целый следующий урок. Если вы попали сюда напрямую из поиска: весь маршрут из десяти уроков — самоучитель Pandas с нуля, и очистка данных здесь четвёртая ступень. Пройдите тест ниже и решите практическую задачу: почистите мини-выгрузку руками, без подсказок конвейера. Именно на грязных данных закрепляется то, что мы сегодня разобрали.
Сначала предскажи ответ в голове — это главный навык программиста.
import pandas as pd
df = pd.DataFrame({"a": [1, None, 3], "b": [None, None, 4]})
print(df.isna().sum().sum())
import pandas as pd
df = pd.DataFrame({"x": [1, 1, 2, 2, 3]})
print(df.drop_duplicates().shape)
1. Что вернёт df.isna().sum() для таблицы заказов?
2. Что сделает df.dropna(subset=["city"])?
3. Почему fillna(0) опасен для столбца с ценами?
4. df.duplicated().sum() вернул 3. Сколько строк удалит df.drop_duplicates()?
5. Почему pd.to_numeric(errors="coerce") лучше astype для грязного столбца?
Почистите мини-выгрузку из пяти строк: заполните пропуск в price медианой, впишите «неизвестен» вместо пропущенного города, приведите price к int64, удалите дубликат и пересоберите индекс. Выведите итоговую таблицу и сумму цен.
Как обработать пропущенные значения (NaN) в pandas?
Сначала найдите их: df.isna().sum() покажет число пропусков по столбцам. Дальше три пути: удалить строки через dropna (если пропусков мало), заполнить через fillna (медиана для чисел, «неизвестен» или мода для категорий) и привести скрытые маркеры вроде «нет данных» к NaN через replace. Нулём заполняйте только когда отсутствие по смыслу равно нулю.
Как удалить дубликаты в pandas?
Методом drop_duplicates(): без параметров он удаляет полностью совпадающие строки, оставляя первое вхождение. Для дублей по ключу укажите subset=["order_id"], параметр keep="last" оставит последнее вхождение. Перед удалением проверьте масштаб: df.duplicated().sum() покажет число повторов.
Что лучше для заполнения пропусков — среднее или медиана?
То, которое не искажает столбец. Медиану берите для числовых столбцов с выбросами: в уроке цену заполняли медианой 25 990 — она устояла перед дорогими ноутбуками, которые тянут среднее вверх. Для столбца без выбросов годится среднее (таблица выбора заменителей в уроке), категориальным столбцам — метка «неизвестен» или мода, а ноль — только когда отсутствие по смыслу равно нулю.
Чем NaN отличается от None и пустой строки?
NaN — числовое значение «нет данных» из NumPy: isna() ловит его, а также None и NaT, но не пустую строку и не текст вроде «нет данных» — для pandas это обычные значения. Поэтому маркеры пропусков из файлов сводят к NaN через replace, и только потом применяют dropna и fillna.
Почему числовой столбец pandas читает как object?
В столбце встретился хоть один текст — «нет данных», пробел, запятая вместо точки, — и pandas объявляет весь столбец текстовым (object). Диагноз: df["price"].dtype. Лечение: replace маркеров на NaN, затем pd.to_numeric(errors="coerce"), затем astype до нужного типа после заполнения пропусков.
Понравился урок? Сошлитесь на него
«Ноль — легальное число, pandas не отличит «данных нет» от «цена действительно ноль».»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
Pandas · Урок 2
Чтение файлов в Pandas: read_csv, read_excel и знакомство с данными
read_csv со всеми нужными параметрами и примерами: разделители, кодировки с кириллицей, чтение куска большого файла и первые ошибки, которые вы увидите на практике.
Pandas · Урок 3
Выборка данных в Pandas: loc, iloc и фильтрация строк по условию
loc и iloc, булева маска, isin и between — достаём из таблицы заказов именно те строки и столбцы, которые нужны, и не попадаемся на SettingWithCopyWarning.
Pandas · Урок 5
Группировка groupby в Pandas: агрегация как в Excel, только мощнее
groupby, agg и pivot_table — считаем выручку по городам и товарам, строим сводные таблицы и не теряем строки с NaN.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 3
Ячейки в openpyxl: адреса, значения и типы данных
Два способа попасть в ячейку: по адресу и по координатам. Числа против строк, ловушка «2026 как текст», очистка через None и чтение значений обратно.
openpyxl типы данных
json · Урок 3
Типы JSON и Python: таблица соответствий
Шесть пар перевода между JSON и Python: object — dict, array — list, true — True, null — None. И два типа, которые формат не понимает вовсе.
json типы данных pythonjson типы python
Pandas · Урок 10
Проект на Pandas: полный анализ продаж интернет-магазина
Финальный проект раздела: сырая выгрузка заказов, чистка по чек-листу, выручка и средний чек, топ-товары, сводные по городам, тренд по месяцам и выводы для руководителя.
проект pandasанализ данных python pandas