Перейти к содержанию

172 уроков, 13 библиотек и челлендж «Что выведет код?» — бесплатно, код прямо в браузере

Начать обучение
Урок 4 из 10 Средний 40 мин 120 XP

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

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 именно там, где значение отсутствует:

isna: где именно пропуски
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 — тогда с ними можно работать одними методами:

replace: скрытый пропуск становится видимым
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"). Давайте честно посмотрим, что будет:

astype спотыкается о NaN
df["price"].astype("int64")
Вывод

pandas.errors.IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer
Блок намеренно не запускается в песочнице: ошибка здесь — часть урока. Так выглядит конец traceback: IntCastingNaNError — подвид ValueError. astype не умеет превращать NaN в целое число, потому что у NaN нет целочисленного представления.

Ошибка честная: целое число не может быть «отсутствующим», NaN живёт только в float. Порядок исправления типов всегда один: сначала привести столбец к float, позволив NaN, — для этого есть pd.to_numeric с параметром errors="coerce":

to_numeric: числа с запасным выходом
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() и не присвоить результат — вы просто посмотрите на пустую операцию:

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 именно из-за дорогих ноутбуков.

fillna: город и цена
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: приводим столбец к правильному типу

Пропусков больше нет — значит, злополучная ошибка из прошлого раздела больше не грозит, и цену можно перевести в целые числа:

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 — последним, чтобы сравнивать уже приведённые к одному формату строки.

clean_orders.py — полный конвейер
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.

Чек-лист очистки данных

Сожмём урок в рабочий ритуал. Прогоняйте его по памяти на каждом новом датасете — в этом порядке:

  1. info() — сколько строк, где неполные столбцы, какие типы;
  2. isna().sum() — точная карта пропусков по столбцам;
  3. replace — маркеры вроде «нет данных» и «-» превратить в NaN;
  4. pd.to_numeric(errors="coerce") — числовые столбцы, приехавшие как object;
  5. drop_duplicates — дубликаты целиком и по ключу через subset;
  6. dropna или fillna — решить для каждого столбца, с медианой для чисел и «неизвестен» для категорий;
  7. astype — привести типы к финальному виду, когда NaN не осталось;
  8. 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)
Проверь себя
0 / 5

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 для грязного столбца?

Карточки терминов
Запомнено: 0 / 6
Практика

Почистите мини-выгрузку из пяти строк: заполните пропуск в price медианой, впишите «неизвестен» вместо пропущенного города, приведите price к int64, удалите дубликат и пересоберите индекс. Выведите итоговую таблицу и сумму цен.

practice.py
Вопросы и ответы по уроку

Как обработать пропущенные значения (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-канал или свой блог — так о проекте узнают новые читатели.

TelegramVK

Похожие уроки по темам

Подобраны автоматически по пересечению тем и ключевых слов.