Python: от данных до своей сводки Урок 31 из 56
Грязные данные: пропуски, дубли и типы
Тридцатый урок курса по Python. Данные приходят сломанными: числа строками, город в трёх написаниях, строка-близнец и пустая ячейка. Разбираем `isna`, `to_numeric` с `errors="coerce"`, `astype`, `drop_duplicates` — и главный вопрос урока: выбросить пропуск или заполнить, и чем за это платят.
Зачем это нужно
До сих пор таблицы в уроках были чистыми, потому что их писали мы. Настоящие приходят иначе: сумма — текст с пробелом внутри, город написан три раза по-разному, одна строка задвоилась при выгрузке, а в ячейке стоит «н/д».
Уборка — не разовое занудство, а часть программы, и у неё есть правило: каждое действие оставляет след. Сколько строк было, сколько стало, что именно изменилось. Таблица, про которую уже никто не скажет, что с ней сделали, хуже грязной.
Сразу целиком
Файл uborka.py. Восемь строк, в которых сломано всё, что обычно ломается.
"""Урок 30: починить то, что пришло сломанным.
Восемь строк, в которых сломано всё, что обычно ломается: пропуски, дубль,
числа строками, город, написанный тремя способами. Чиним по одному и считаем,
что изменилось.
"""
import pandas as pd
rows = [
("Костанай", "еда", "4 200", "2026-01-03"),
("костанай ", "топливо", "18 500", "2026-01-05"),
("Рудный", "еда", "2 600", "2026-01-07"),
("Рудный", "еда", "2 600", "2026-01-07"),
("КОСТАНАЙ", "связь", "н/д", "2026-01-09"),
("Рудный", "аренда", "62 000", "2026-01-11"),
("Костанай", "аренда", "95 000", None),
("Астана", "еда", "7 300", "2026-01-15"),
]
df = pd.DataFrame(rows, columns=["city", "kind", "amount", "day"])
print(df)
print("типы:", dict(df.dtypes.astype(str)))
print()
print("== что сломано: смотрим до того, как чинить")
print("пропусков по столбцам:", df.isna().sum().to_dict())
print("полных дублей:", int(df.duplicated().sum()))
print("городов было:", sorted(df["city"].unique()))
print()
print("== числа: текст в число, мусор в пропуск")
df["amount"] = pd.to_numeric(df["amount"].str.replace(" ", "", regex=False), errors="coerce")
print("тип теперь:", df["amount"].dtype, "| пропусков:", int(df["amount"].isna().sum()))
print()
print("== даты: то же самое, но errors='coerce' для дат")
df["day"] = pd.to_datetime(df["day"], errors="coerce")
print("тип теперь:", df["day"].dtype, "| пропусков:", int(df["day"].isna().sum()))
print()
print("== города: три написания — один город")
df["city"] = df["city"].str.strip().str.capitalize()
print("городов стало:", sorted(df["city"].unique()))
print()
print("== дубли: считаем, потом убираем")
before = len(df)
df = df.drop_duplicates()
print(f"строк: {before} → {len(df)}")
print()
print("== пропуски: выбросить или заполнить — решение, а не привычка")
print("строк без суммы:", int(df["amount"].isna().sum()))
paid = df.dropna(subset=["amount"])
print("считаем по:", len(paid), "строкам | сумма:", int(paid["amount"].sum()))
print("а если заполнить нулём:", int(df["amount"].fillna(0).sum()), "— то же число, но среднее уже соврёт")
print("среднее по имеющимся:", round(paid["amount"].mean(), 1),
"| с нулём:", round(df["amount"].fillna(0).mean(), 1))
print()
print("== категория вместо строк: та же таблица, меньше памяти")
print("строкой:", int(df["city"].memory_usage(deep=True)),
"| категорией:", int(df["city"].astype("category").memory_usage(deep=True)))
Выводит:
city kind amount day
0 Костанай еда 4 200 2026-01-03
1 костанай топливо 18 500 2026-01-05
2 Рудный еда 2 600 2026-01-07
3 Рудный еда 2 600 2026-01-07
4 КОСТАНАЙ связь н/д 2026-01-09
5 Рудный аренда 62 000 2026-01-11
6 Костанай аренда 95 000 NaN
7 Астана еда 7 300 2026-01-15
типы: {'city': 'str', 'kind': 'str', 'amount': 'str', 'day': 'str'}
== что сломано: смотрим до того, как чинить
пропусков по столбцам: {'city': 0, 'kind': 0, 'amount': 0, 'day': 1}
полных дублей: 1
городов было: ['Астана', 'КОСТАНАЙ', 'Костанай', 'Рудный', 'костанай ']
== числа: текст в число, мусор в пропуск
тип теперь: float64 | пропусков: 1
== даты: то же самое, но errors='coerce' для дат
тип теперь: datetime64[us] | пропусков: 1
== города: три написания — один город
городов стало: ['Астана', 'Костанай', 'Рудный']
== дубли: считаем, потом убираем
строк: 8 → 7
== пропуски: выбросить или заполнить — решение, а не привычка
строк без суммы: 1
считаем по: 6 строкам | сумма: 189600
а если заполнить нулём: 189600 — то же число, но среднее уже соврёт
среднее по имеющимся: 31600.0 | с нулём: 27085.7
== категория вместо строк: та же таблица, меньше памяти
строкой: 725 | категорией: 344
Разбор
Сначала посмотреть, потом чинить
Три строчки, с которых начинается работа с любой чужой таблицей:
df.isna().sum() сколько пропусков в каждом столбце
df.duplicated().sum() сколько строк-близнецов
df["city"].unique() сколько на самом деле разных значений
Третья обычно и открывает глаза: пять «городов» вместо трёх, потому что где-то пробел, где-то заглавные. value_counts() покажет то же самое с числами — и сразу видно, какие написания редкие, то есть, скорее всего, ошибочные.
В примере три написания сводит str.strip().str.capitalize(), и для урока этого хватает. В работе так делать опасно: capitalize опускает все остальные буквы, а значит ломает аббревиатуры (ТОО), двойные имена и названия через дефис. Как только написаний становится больше трёх, заводят таблицу соответствий — словарь «как пришло → как правильно» — и применяют её через replace; всё, чего в словаре нет, остаётся видимым и попадает в журнал.
Числа: to_numeric вместо astype
astype(float) на столбце, где встретится «н/д», падает целиком: одна плохая ячейка — и ни одного числа. pd.to_numeric(..., errors="coerce") переводит что может, а что не может — превращает в NaN. Это честный ход: непонятное значение становится известно непонятным, а не нулём.
Пробелы внутри числа убираются до перевода. И вот здесь ждёт ловушка, которая стоит отдельной программы: пробел пробелу рознь. Excel ставит внутри числа неразрывный пробел, а иногда узкий неразрывный; на экране они выглядят точно как обычный, а replace(" ", "") их не трогает.
import pandas as pd
# Пробелы записаны кодами нарочно: в файле они выглядят как обычные.
# \u00a0 — неразрывный пробел из Excel, \u202f — узкий неразрывный
raw = pd.Series(["1\u00a0200,50", "18\u202f500 ₸", "12%", "н/д"])
only_space = raw.str.replace(" ", "", regex=False)
print("убрали только обычный пробел:", pd.to_numeric(only_space, errors="coerce").tolist())
clean = (raw.str.replace("\u00a0", "", regex=False)
.str.replace("\u202f", "", regex=False)
.str.replace(" ", "", regex=False)
.str.replace("₸", "", regex=False)
.str.replace("%", "", regex=False)
.str.replace(",", ".", regex=False))
print("убрали всё лишнее: ", pd.to_numeric(clean, errors="coerce").tolist())
Выводит:
убрали только обычный пробел: [nan, nan, nan, nan]
убрали всё лишнее: [1200.5, 18500.0, 12.0, nan]
Первая строка — четыре пропуска: обычного пробела в этих числах не было ни одного. Поэтому валюту, проценты и каждый вид пробела снимают по отдельности, а десятичную запятую меняют на точку — либо один раз регулярным выражением, когда их становится много.
С процентом есть отдельный вопрос, и его надо решить до подсчёта: 12% — это число 12 или доля 0.12? Ответ зависит от того, что дальше делают со столбцом, и его лучше записать рядом в коде, чем вспоминать через месяц.
Даты чинятся тем же приёмом: pd.to_datetime(..., errors="coerce").
Типы: astype и целые с пропусками
astype — про приведение, когда данные уже в порядке: astype("category"), astype("str"), astype("Int64").
Ловушка, на которую наступают все: astype(int) на столбце с пропуском падает, потому что обычное целое не умеет быть пустым:
IntCastingNaNError: Cannot convert non-finite values (NA or inf) to integer
Выход — Int64 с большой буквы: это целое, которое умеет быть <NA>. Обычное int64 — нет.
Дубли: полные и по ключу
df.duplicated() находит строки, целиком совпадающие с уже встреченной. drop_duplicates() их убирает.
Но чаще ломает другое: дубль по ключу, когда строки отличаются в одном столбце, а ключ у них один. Тогда subset и keep решают, что считать дублем и какую строку оставить:
df.drop_duplicates(subset=["city", "day"], keep="last")
keep="last" — если поздняя запись считается исправлением ранней. Это решение о смысле данных, а не техническая деталь: выбирая first или last, вы отвечаете на вопрос, какая из двух записей правдивее.
Чем это кончается, если не заметить, показал двадцать девятый урок: дубль в ключе справочника размножает строки при соединении.
Пропуски: выбросить или заполнить
Здесь урок доходит до места, где техника кончается и начинается честность.
dropna() выбрасывает строки с пропусками — целиком или по subset. fillna(значение) заполняет. И то и другое — решение, а не привычка.
Смотрите на числа из примера. Сумма не изменилась: fillna(0) добавил ноль, ноль в сумме ничего не значит. А среднее упало с 31 600 до 27 086 — потому что пустой чек стал чеком на ноль тенге, которого не было. Одна и та же операция для одной цифры безобидна, для другой — ложь.
Когда заполнять можно:
- значение по умолчанию известно и оно настоящее: количество не указано — значит один;
- ряд по времени, где предметная область говорит, что последнее опубликованное значение действует до следующего обновления (
ffill): курс выходного дня — это курс пятницы не потому, что его измерили в субботу, а потому, что так устроено правило. Это допущение, и его надо записать: и в коде, и рядом с отчётом. Хорошо, если заполненные значения помечены отдельным столбцом — тогда видно, где измерение, а где перенос; - пропуск — это словами выразимое «неизвестно», и вы ставите метку, а не число:
"неизвестно","не указан". Метка — не выдумка, она говорит правду.
Когда нельзя:
- заполнять числовой пропуск средним, нулём или соседним значением «чтобы посчиталось». Это придуманные данные, и отличить их потом от настоящих нельзя.
Правило, которое стоит записать: пропуск — это факт, а не помеха. Сколько их, в каких строках и что вы с ними сделали — часть ответа, а не мусор перед ответом.
Заодно: категория вместо строк
astype("category") для столбца, где значений мало, а строк много: пять городов хранятся один раз, а в столбце остаются номера. В примере 725 байт превращаются в 344, а на миллионе строк двадцать четвёртый урок показал 155 МБ против 9.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Чем
to_numeric(errors="coerce")лучшеastype(float)для столбца из чужого файла? - Почему
fillna(0)не изменил сумму, но изменил среднее? - Когда заполнить пропуск — честно, а когда — выдумать данные?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
1. Предскажите. Что напечатает эта программа?
import pandas as pd
amounts = pd.Series(["1200", "н/д", "4990"])
numbers = pd.to_numeric(amounts, errors="coerce")
print(numbers.tolist())
print("сумма:", numbers.sum(), "| среднее:", numbers.mean(), "| count:", int(numbers.count()))
2. Заполните пропуск. Вместо ... сделайте так, чтобы «н/д» стало пропуском, а программа не упала.
# одно плохое значение не должно ломать весь столбец
import pandas as pd
amounts = pd.Series(["1200", "н/д", "4990"])
numbers = pd.to_numeric(amounts, ...)
print(numbers.isna().sum(), "| тип:", numbers.dtype)
3. Почините. Программа падает с IntCastingNaNError. Год должен остаться целым, а пропуск — пропуском.
# обычное целое не умеет быть пустым
import pandas as pd
years = pd.Series([2024.0, 2025.0, None])
print(years.astype(int).tolist())
Задание
Обязательное. Дана выгрузка из восьми строк: город в трёх написаниях, суммы с пробелами, «н/д» и «—» вместо чисел, одна строка-близнец. Приведите её в порядок и напечатайте журнал уборки: сколько строк пришло, сколько было городов и сколько стало, сколько значений не стали числами, сколько дублей убрано и сколько строк дошло до отчёта. Затем посчитайте по городам число чеков, сумму и средний — только по строкам, где сумма известна.
Ожидаемый вывод:
журнал уборки:
строк пришло: 8
городов пришло: 5
городов стало: 3 — ['Астана', 'Костанай', 'Рудный']
не стали числами: 2
дублей убрано: 1
строк в отчёт: 5 из 7
по городам:
чеков всего средний
city
Костанай 3 117700.0 39233.0
Рудный 2 64600.0 32300.0
суммы сходятся: True
Готово, когда: вывод совпадает построчно; числа получены через to_numeric(errors="coerce"), а не заменами по одной; города приведены к одному виду до подсчёта уникальных; строки без суммы не заполнены нулём, а исключены из отчёта; в журнале видно, что Астана до отчёта не дошла — её единственный чек остался без суммы.
На своих данных. Возьмите любой присланный вам файл и напишите к нему журнал уборки: три строчки «посмотреть» до и столько же после. Если после уборки строк стало меньше больше чем на пару процентов — остановитесь и посмотрите, что именно вы выбросили.
По желанию.
- Сравните
keep="first"иkeep="last"на своих дублях и решите, какая запись правдивее. - Замерьте
memory_usage(deep=True)до и послеastype("category")на своём текстовом столбце. - Попробуйте
ffill()на ряду по дням и объясните себе, в каком случае это честно.
Куда это встанет в проекте
Одиннадцатый шаг: между источником и отчётом появляется уборка. sholu/tazalau.py возвращает две вещи — таблицу, годную к счёту, и журнал того, что он сделал.
Правила у него ровно те, что в уроке: строка без ключа выбрасывается (это не пропуск в данных, а отсутствие самой строки), повторённая пара «страна и год» — тоже, не-число становится пропуском, а не нулём, а код страны переводится в category.
Отчёт больше ничего этого не решает — он только считает. За ним осталось одно решение, и оно про слова: страна, которой нет в справочнике, получает метку белгісіз вместо пустой ячейки. Так закрывается долг двадцать девятого урока: пустая ячейка в отчёте — вопрос, на который никто не отвечает, а метка отвечает честно.
Долги. Журнал уборки печатается на экран и исчезает вместе с ним. Ему место рядом с отчётом — в файле, который можно сравнить с прошлым запуском; до этого дойдём там же, где до сервера.
Ответы
Показать ответы
На вопросы
- Потому что
astype(float)падает на первом же непонятном значении и не переводит ничего, аto_numeric(errors="coerce")переводит всё, что может, а непонятное делаетNaN— то есть «известно непонятным». Дальше с этим можно работать: посчитать, показать, решить. - Потому что ноль в сумме ничего не добавляет, а в среднем добавляет ещё одно слагаемое. Пустой чек превратился в чек на ноль тенге, которого никогда не было, и среднее упало с 31 600 до 27 086.
- Честно — когда заполняется настоящее значение по умолчанию, когда правило предметной области само говорит, что прежнее значение действует до следующего обновления, и когда вместо числа ставится метка «неизвестно». В первых двух случаях допущение записывают, а заполненные значения лучше помечать. Выдумка — когда пропуск заполняют средним или нулём, чтобы формула посчиталась: такие данные потом не отличить от настоящих.
К разминке
errors="coerce"превращает «н/д» вNaN, и весь столбец становится дробным.sum()иmean()пропуск не считают,count()показывает, по скольким значениям они посчитаны.
[1200.0, nan, 4990.0]
сумма: 6190.0 | среднее: 3095.0 | count: 2
errors="coerce". Без негоto_numericпадает на первом же непереводимом значении.
import pandas as pd
amounts = pd.Series(["1200", "н/д", "4990"])
numbers = pd.to_numeric(amounts, errors="coerce")
print(numbers.isna().sum(), "| тип:", numbers.dtype)
1 | тип: float64
astype("Int64")— целое с большой буквы, которое умеет быть пустым. Обычноеintпустым быть не умеет, поэтому и падает.
import pandas as pd
years = pd.Series([2024.0, 2025.0, None])
print(years.astype("Int64").tolist())
[2024, 2025, <NA>]
К заданию
Города приводятся к одному виду до того, как считать уникальные: иначе отчёт получит три Костаная и все три с неполными суммами.
Строки без суммы исключены, а не заполнены нулём, и именно поэтому Астана в отчёт не попала: её единственный чек остался без числа. Это правильный результат, но он обязан быть виден — для того журнал и печатается. Отчёт, из которого молча пропал город, ничем не отличается от отчёта, где его никогда не было.
Источники
- Работа с пропущенными данными —
isna,dropna,fillnaи правила, по которымNaNведёт себя в вычислениях. - Типы и приведение —
astype,to_numericи целые, которые умеют быть пустыми. - Категориальный тип — когда столбец со словами стоит хранить номерами.
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.