Shanraq.org Shanraq.org
Грязные данные: пропуски, дубли и типы
IT

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.

Карта урока

Карта урока: посмотреть, починить, записать

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Чем to_numeric(errors="coerce") лучше astype(float) для столбца из чужого файла?
  2. Почему fillna(0) не изменил сумму, но изменил среднее?
  3. Когда заполнить пропуск — честно, а когда — выдумать данные?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

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.

Отчёт больше ничего этого не решает — он только считает. За ним осталось одно решение, и оно про слова: страна, которой нет в справочнике, получает метку белгісіз вместо пустой ячейки. Так закрывается долг двадцать девятого урока: пустая ячейка в отчёте — вопрос, на который никто не отвечает, а метка отвечает честно.

Долги. Журнал уборки печатается на экран и исчезает вместе с ним. Ему место рядом с отчётом — в файле, который можно сравнить с прошлым запуском; до этого дойдём там же, где до сервера.

Ответы

Показать ответы

На вопросы

  1. Потому что astype(float) падает на первом же непонятном значении и не переводит ничего, а to_numeric(errors="coerce") переводит всё, что может, а непонятное делает NaN — то есть «известно непонятным». Дальше с этим можно работать: посчитать, показать, решить.
  2. Потому что ноль в сумме ничего не добавляет, а в среднем добавляет ещё одно слагаемое. Пустой чек превратился в чек на ноль тенге, которого никогда не было, и среднее упало с 31 600 до 27 086.
  3. Честно — когда заполняется настоящее значение по умолчанию, когда правило предметной области само говорит, что прежнее значение действует до следующего обновления, и когда вместо числа ставится метка «неизвестно». В первых двух случаях допущение записывают, а заполненные значения лучше помечать. Выдумка — когда пропуск заполняют средним или нулём, чтобы формула посчиталась: такие данные потом не отличить от настоящих.

К разминке

  1. errors="coerce" превращает «н/д» в NaN, и весь столбец становится дробным. sum() и mean() пропуск не считают, count() показывает, по скольким значениям они посчитаны.
[1200.0, nan, 4990.0]
сумма: 6190.0 | среднее: 3095.0 | count: 2
  1. 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
  1. astype("Int64") — целое с большой буквы, которое умеет быть пустым. Обычное int пустым быть не умеет, поэтому и падает.
import pandas as pd

years = pd.Series([2024.0, 2025.0, None])
print(years.astype("Int64").tolist())
[2024, 2025, <NA>]

К заданию

Города приводятся к одному виду до того, как считать уникальные: иначе отчёт получит три Костаная и все три с неполными суммами.

Строки без суммы исключены, а не заполнены нулём, и именно поэтому Астана в отчёт не попала: её единственный чек остался без числа. Это правильный результат, но он обязан быть виден — для того журнал и печатается. Отчёт, из которого молча пропал город, ничем не отличается от отчёта, где его никогда не было.

Источники

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

Комментарии (0)

Пока нет комментариев. Будьте первым.