Семестр 3 · Модуль 2 · Неделя 5

Очистка данных

аудит качества · пропуски · дубликаты · типы · текст и apply · выбросы

2 ч теории. Презентация — 30 мин мини-лекция, далее live-coding и задачи семинара.

С прошлой лекции: почему df['Age'][df['Age'] > 30] = 99 не записывает значения? Как записать правильно? Сегодня продолжим тему «изменяю данные» — но уже для целых колонок, и здесь тоже действует правило «присвой результат».

К концу лекции вы сможете: привести грязный датасет к пригодному виду

  1. проводить аудит качества (dtypes/isna().sum()/duplicated().sum());
  2. обрабатывать пропуски (dropna/fillna) с обоснованной стратегией;
  3. применять правило «мутация vs копия»;
  4. находить и удалять дубликаты;
  5. чинить типы (astype, to_numeric, to_datetime);
  6. чистить тексты (.str, map/apply) и выбирать векторизацию;
  7. находить выбросы (IQR/z-score) и обосновывать решение.
Каждый пункт — пункт вашего чек-листа самооценки после семинара. Какой из них вы уже пробовали на неделе 4?

Очистка — 80% работы data scientist; чистого файла не бывает

Что произойдёт с результатом анализа, если пропуски и опечатки не обработать?

Открываем dirty_salary глазами: что видно сразу?

import pandas as pd dirty = pd.read_csv('dirty_salary.csv') dirty.head(8) employee_id name department salary age hire_date 0 emp_001 Иванова Анна HR 120 000 31.0 2024-05-01 1 emp_002 Петров Иван IT 90k 29.0 2024-05-02 2 emp_003 Сидорова Мария Sales 75 500 NaN 2024-05-03 3 emp_001 Иванова Анна HR 120 000 31.0 2024-05-01 4 emp_004 Козлов Пётр IT 145k 35.0 2024-05-04 5 emp_005 Новикова Ольга HR 88 000 27.0 2024-05-05 6 emp_006 Морозов Дмитрий Sales 60 000 NaN 2024-05-06 7 emp_007 Волкова Анна IT 98k 33.0 2024-05-07
Перечислите глазами, что здесь «сломано» — до запуска кода.

Аудит качества: три вопроса к датасету

dirty.dtypes employee_id str name str department str salary str age float64 hire_date str dtype: object dirty.isna().sum() employee_id 0 name 0 department 1 salary 2 age 5 hire_date 0 dtype: int64 dirty.duplicated().sum() # → 1 (полных копий) dirty.duplicated(subset=['employee_id']).sum() # → 1 (дубликатов по ключу)

Аудит — это «карта ремонта»: колонка → проблема → стратегия. Три вызова заменяют просмотр 16 строк глазами; для 100 000 строк они незаменимы.

Какие три типа проблем вы видите в сводке?

NaN — «дырка» в данных; isna()/notna() дают маску True/False

dirty['age'].notna().head(3) 0 True 1 True 2 False Name: age, dtype: bool dirty['age'].isna().sum() # → 5 — сколько пропусков

NaN (Not a Number) — отметка «данных нет». isna() — маска True там, где пропуск; notna() — наоборот. .sum() по маске считает число пропусков; .mean() — долю.

Что такое NaN и почему .sum() по маске isna() считает пропуски?

dropna: удалять — только когда нужно (how/thresh/subset)

len(dirty) # 16 len(dirty.dropna()) # 8 — how='any': любая «дырка» = удалить строку len(dirty.dropna(subset=['salary'])) # 14 — только если нет salary len(dirty.dropna(subset=['age'])) # 11 — только если нет age

dropna() без параметров удаляет все строки с хоть одним пропуском (how='any') — у нас выбросило бы половину данных. subset=['col'] удаляет только когда пропуск в важной колонке; thresh=N — оставляет строки с ≥ N непустыми значениями. Выбор = вопрос «какие пропуски я могу себе позволить потерять?»

В какой колонке dropna() без параметров — плохая идея? Почему?

fillna: чем заполнять — по смыслу колонки

dirty['age'].median() # 31.0 dirty['age'].fillna(dirty['age'].median()) # медиана (устойчива к выбросам) dirty['department'].fillna('unknown') # отдельная метка «нет данных» dirty['age'].fillna(dirty['age'].median()).isna().sum() # 0 dirty['department'].fillna('unknown').isna().sum() # 0

Заполняем по типу колонки: числовые — медиана (реже среднее), категориальные — отдельная метка 'unknown' (а не мода!), временные ряды — method='ffill'/'bfill' (следующее/предыдущее значение).

Почему для зарплаты лучше медиана, а не среднее? А для категории — 'unknown', а не мода?

Стратегия для пропуска зависит от типа колонки

Тип колонкиСтратегияПочему
Числоваямедиана (устойчива), реже среднеесреднее «тянется» к выбросам
Категориальнаяотдельная метка 'unknown'мода искажает частоты; «нет данных» — честный ответ
Временной рядffill/bfill (с limit)соседние по времени значения ближе к пропущенному
«Ключевая»dropna(subset=[...])если строки без неё бессмысленны (нет salary — нет анализа)
Большая доляdropna/dropna(thresh=...)заполнять 30% данных — значит выдумывать 30% данных

Стратегия — это не «какая функция», а ответ на вопрос «что этот пропуск означает для анализа». Функцию выбираем последней.

Доля пропусков в колонке больше ~30% — что это сигнал: заполнить или удалить колонку/строку?

Методы возвращают НОВУЮ копию — результат присваивай

Почему df.dropna() сам по себе «ничего не делает»? Что обязательно нужно написать?

df.dropna() «не сработала»: результат не присвоен

dirty.dropna() # «удалю пропуски» dirty.isna().sum() # «проверяю...» age 5 # ← пропуски никуда не делись! salary 2 department 1 dirty = dirty.dropna(subset=['salary']) # ← присвоить результат!

dropna() вернула новую таблицу без пропусков — но мы её выбросили, не присвоив. Исходный dirty не изменился. Это и есть правило слайда 10 в действии. В pandas 2.x та же история — не предупреждение, а молча «не сработало».

Студент сделал df.fillna(0) и пропуски остались. Что он забыл?

Дубликаты: duplicated()/drop_duplicates(keep, subset)

dirty.duplicated() # True/False по строкам dirty.drop_duplicates().shape # (15, 6) — убрали полную копию dirty.drop_duplicates(subset=['employee_id'], keep='first').shape # (15, 6) — 1 дубликат по ключу dirty.drop_duplicates(subset=['employee_id'], keep=False).shape # (14, 6) — удалил ОБЕ строки emp_001

duplicated() — маска True/False (как isna()). drop_duplicates(subset=[ключ]) удаляет повторы по ключу; keep='first'/'last' — какую строку оставить, keep=False — удалить все копии. Вопрос не «как удалить», а «что считать дубликатом».

Чем drop_duplicates() отличается от drop_duplicates(subset=['employee_id'])?

Дубликат настоящий vs артефакт импорта

dirty[dirty.duplicated(subset=['employee_id'])] employee_id name department salary age hire_date 3 emp_001 Иванова Анна HR 120 000 31.0 2024-05-01

Повторилась ровно та же строка — это артефакт импорта, удаляем. Ключ тот же, а данные разные — это отдельные события, их нельзя удалять без осмысления.

В продажах два чека с одним номером заказа. Это баг выгрузки или допродажа? Кто решает?

«Число» стало строкой: salary — str, .mean() бросает TypeError

dirty['salary'].dtype # str — Pandas честно сказал: это строки dirty['salary'].mean() TypeError: Cannot perform reduction 'mean' with string dtype

В pandas 2.x на object-колонке это же выражение возвращало NaN — тихо; в 3.0 ошибка явная.

Pandas не умеет складывать «120 000» и «90k»: это строки. .mean() на строковой колонке падает с TypeError. Причина — в колонке есть не-числа: пробелы, буква «k», запятая. Проверка — dtypes после чтения.

Почему Pandas не угадал числа в salary? Что внутри колонки мешает?

astype: когда можно, а когда нет

age_clean = dirty['age'].fillna(dirty['age'].median()) age_clean.astype('int64').head(3) 0 31 1 29 2 31 Name: age, dtype: int64 dirty['salary'].astype('int64') ValueError: invalid literal for int() with base 10: '120 000'

astype меняет тип, не меняя значения. Для age (числа с плавающей точкой после fillna) — работает. Для salary («120 000») — падает: это не число, а текст. Строки-числа переводятся через to_numeric ПОСЛЕ нормализации формата.

Почему astype('int64') сработал для age, но упал для salary?

to_numeric(errors='coerce'): нераспарсенное → NaN, конвейер живёт

s = dirty['salary'].str.replace(' ', '', regex=False) s = s.str.replace('k', '000', regex=False) s = s.str.replace(',', '.', regex=False) sal = pd.to_numeric(s, errors='coerce') sal.head(8) 0 120000.0 1 90000.0 2 75500.0 3 120000.0 4 145000.0 5 88000.0 6 60000.0 7 98000.0 Name: salary, dtype: float64 sal.notna().sum() # 14 из 16 — две строки не распарсились в NaN

pd.to_numeric переводит строки в числа. errors='coerce' — «мягкий» режим: что не распарсилось, становится NaN, и конвейер не падает. Потом решаем: 2 строки без зарплаты — удалить (dropna(subset=['salary'])).

Что делает errors='coerce'? Зачем он, а не исключение?

Нормализация «число-строк»: цепочка str.replace → to_numeric

s = dirty['salary'].str.replace(' ', '', regex=False) # "120 000" → ? s = s.str.replace('k', '000', regex=False) # "90k" → ? s = s.str.replace(',', '.', regex=False) # "12,5" → ? pd.to_numeric(s, errors='coerce').head(6)

Сначала привести к единому виду строк (убрать пробелы, суффикс «k», заменить «,» на «.»), потом ОДИН раз перевести в число. to_numeric сразу на «120 000» — ValueError: пробел не входит в число.

Что вернёт каждая строка для «120 000», «90k», «12,5»? Почему pd.to_numeric("120 000") сразу падает?

to_datetime(format=, errors='coerce'): строки дат → datetime64

dt = pd.to_datetime(dirty['hire_date'], format='%Y-%m-%d', errors='coerce') dt.dtype # datetime64[us] dt.iloc[0] # Timestamp('2024-05-01 00:00:00') dirty['hire_date'].str[5:7].value_counts() # можно группировать по месяцам

to_datetime без format на нестандартных строках может «угадать» неверно; задаём format явно. errors='coerce' — мусорная строка станет NaN, а не уронит конвейер. Зачем даты? На неделе 6 из них будем вытаскивать месяц и группировать.

Что вернёт to_datetime для строки «не дата» с errors='coerce'?

.str-аксессор: векторизованные строковые методы

dirty['name'].str.lower().head(3) 0 иванова анна 1 петров иван 2 сидорова мария Name: name, dtype: str dirty['name'].str.strip() # убрать пробелы по краям dirty['name'].str.contains('ова', na=False) # содержит подстроку? dirty['name'].str.startswith('И') # начинается с «И»? pd.Series(['123', 'abc', '12.5']).str.isdigit() # True False False

Все строковые методы вызываются через аксессор df['col'].str.* и работают сразу над всеми значениями (векторизованно). contains(na=False) — что делать с NaN: не падать, а считать False.

Что делает na=False в str.contains? Зачем он нужен?

str.extract: вытащить число из «12,5 кг» (мост к regex)

pd.Series(['12,5 кг', '90k', '120 000']).str.extract(r'([\d,]+)') 0 0 12,5 1 90 2 120

str.extract берёт из строки кусок, подходящий под шаблон — регулярное выражение. Скобки (...) — «какую группу вернуть». Здесь — цифры и запятая. Результат — строка; в число переводим to_numeric. Полноценно regex — на неделе 9.

Как перевести извлечённую строку «12,5» в число?

map: замена значений словарём/функцией

gender = {'male': 'М', 'female': 'Ж'} pd.Series(['male', 'female', 'male']).map(gender) 0 М 1 Ж 2 М dtype: str pd.Series([10, 20, 30]).map(lambda x: x * 2) # функция тоже работает

map на Series применяет к каждому значению словарь или функцию. Словарь — для замены «код → название»; функция — для простого поштучного преобразования. Если есть векторный способ (.str, арифметика, np.where) — он быстрее и читается лучше.

Что будет, если в словаре нет значения для элемента?

apply на Series: произвольная функция

ages = pd.Series([10, 20, 30]) ages.apply(lambda a: 'взрослый' if a > 18 else 'ребёнок') 0 ребёнок 1 взрослый 2 взрослый dtype: str

apply прогоняет каждый элемент через функцию — когда логику не записать одной векторной операцией (if/else по условию с текстом). Но: если условие простое, np.where(cond, 'взрослый', 'ребёнок') — то же самое и быстрее. apply вызывает Python-функцию на каждый элемент — медленно на больших данных.

Что быстрее на 100 000 строк: apply или np.where? Почему?

apply на DataFrame (axis=1) и .map (экс-applymap) — обзорно

df_small = pd.DataFrame({'price': [10, 20], 'qty': [3, 4]}) df_small.apply(lambda row: row['price'] * row['qty'], axis=1) 0 30 1 80 dtype: int64 pd.DataFrame({'a': [1.5, 2.5], 'b': [3.5, 4.5]}).map(lambda x: x * 2) a b 0 3.0 7.0 1 5.0 9.0

apply(..., axis=1) — функция на каждую строку; на 100 тыс. строк это медленно — почти всегда заменяется арифметикой колонок (price * qty). Поэлементное применение по всему DataFrame — .map (в pandas 3.0 метод applymap переименован в .map).

Как записать df_small.apply(lambda row: row['price']*row['qty'], axis=1) без apply?

Векторизация вместо apply: быстрее и чище

ЗадачаМедленноВекторизованно
«взрослый/ребёнок» по возрастуages.apply(lambda a: ...)np.where(ages > 18, 'взр.', 'реб.')
выручка = цена × количествоdf.apply(lambda r: r.price*r.qty, axis=1)df['price'] * df['qty']
нормализация текстацикл по строкамdf['name'].str.strip().str.lower()
замена по словарюdf['col'].apply(lambda x: d[x])df['col'].map(d)

apply/цикл — Python-функция на каждый элемент; векторная операция — один проход на C-уровне. Где есть np.where, .str, арифметика, map — они быстрее, короче и не ловят «классику» вроде iterrows.

Что быстрее для salary * 1.1: apply(lambda x: x*1.1) или df['salary'] * 1.1?

Выбросы по IQR: границы и что они ловят

# s — salary после нормализации (слайд 17) и очистки от NaN s = sal.dropna() # 14 значений q1, q3 = s.quantile(0.25), s.quantile(0.75) iqr = q3 - q1 lower, upper = q1 - 1.5 * iqr, q3 + 1.5 * iqr outliers = s[(s < lower) | (s > upper)] outliers 8 12.5 Name: salary, dtype: float64

Числа: q1 = 77250.0, q3 = 107500.0, iqr = 30250.0, lower = 31875.0, upper = 152875.0.

Выброс — значение за границами q1 − 1.5·IQR / q3 + 1.5·IQR. В наших зарплатах это 12.5 — опечатка «12,5», ставшая абсурдной зарплатой. IQR ловит именно ошибки ввода: одно «далёкое» число сигналит об опечатке.

Почему 12.5 — выброс по IQR, хотя это «всего одно число»?

Выбросы по z-score: |z| > 2

z = (s - s.mean()) / s.std() s[z.abs() > 2] 8 12.5 Name: salary, dtype: float64

z-score — сколько стандартных отклонений значение отстоит от среднего. Порог |z| > 2 (иногда 3) — выброс. Оба метода (IQR и z-score) поймали одно и то же — 12.5. Когда методы согласны, решение принимается увереннее.

Почему для скошенных распределений IQR устойчивее z-score?

Выброс: удалять / каппировать / помечать — обосновать

Никогда не «удаляй по умолчанию». Сначала пойми природу выброса: ошибка ввода → удалить; честное экстремальное значение → каппировать или пометить. Решение записывается в markdown-обоснование.

Всегда ли каппирование уместно? А для честной топ-зарплаты 145 000 — каппировать или оставить?

Очистка — процесс с контрольными точками, а не одна функция

сырой CSV → осмотр: info(), isna().sum(), dtypes → решение по каждой колонке # пропуски, дубликаты, типы, текст → проверка: 0 пропусков, верные dtypes, нет дубликатов → clean.csv

На каждом шаге — «как проверить»: .head(), .isna().sum(), .dtypes, .shape.

После каждого шага выводи результат и сверяй с контрольной точкой — не переходи дальше «вслепую».

Зачем проверять качество ДО и ПОСЛЕ одной функцией?

data_quality(df) до и после очистки

def data_quality(df: pd.DataFrame) -> dict: q = pd.DataFrame({ 'dtype': df.dtypes.astype('str'), 'n_missing': df.isna().sum(), 'share_missing': df.isna().mean(), }) return {'by_col': q, 'n_dup': int(df.duplicated().sum())} data_quality(dirty)['by_col'] dtype n_missing share_missing employee_id str 0 0.0000 name str 0 0.0000 department str 1 0.0625 salary str 2 0.1250 age float64 5 0.3125 hire_date str 0 0.0000

data_quality обобщает аудит в функцию, которую можно применить к любому DataFrame: тип, число и доля пропусков, дубликаты. Вызов ДО и ПОСЛЕ очистки — контрольная точка конвейера.

Что показывает share_missing? При какой доле колонку обычно удаляют?

Чистая функция clean(): порядок шагов и df.copy()

def clean(df: pd.DataFrame) -> pd.DataFrame: result = df.copy() # не мутируем вход result['salary'] = ( result['salary'].str.replace(' ', '', regex=False) .str.replace('k', '000', regex=False) .str.replace(',', '.', regex=False) ) result['salary'] = pd.to_numeric(result['salary'], errors='coerce') result['hire_date'] = pd.to_datetime( result['hire_date'], format='%Y-%m-%d', errors='coerce') result['age'] = result['age'].fillna(result['age'].median()) result['department'] = result['department'].fillna('unknown') result = result.drop_duplicates(subset=['employee_id']) result = result.dropna(subset=['salary']) return result clean(dirty).shape # (16, 6) → (13, 6) clean(dirty)['age'].isna().sum() # 0 clean(dirty)['salary'].isna().sum() # 0

Порядок шагов не случаен: сначала типы (str.replace → to_numeric), потом заполнение пропусков, потом дубликаты и «безнадёжные» строки. df.copy() в начале изолирует вход — функция чистая и воспроизводимая. Итог: 16 строк → 13 (минус дубликат emp_001 и 2 строки без зарплаты).

Зачем df.copy() в начале clean()? Что случится без него?

Типичные ошибки недели 5

  1. Мутация без присваивания: df.dropna() / df.fillna(0) «не сработали» — результат не присвоен.
  2. apply там, где достаточно map или векторизации (лишние циклы по элементам).
  3. to_datetime без format на нестандартных строках — парсится неверно; нужен явный формат и errors='coerce'.
  4. Не задан na_values при чтении — строки «N/A», «-» не распознаются как NaN.
  5. drop_duplicates() без subset — дубликат по ключу с другими полями не удалится.
Студент сделал df.fillna(0) и удивлён: пропуски остались. Что он забыл?

Неделя 5 проекта: очистить до clean.csv с обоснованием

Как сегодняшние слайды превращаются в ячейки clean_data.ipynb?

Стоп-вопросы: четыре вопроса недели

  1. Когда выбрать dropna, а когда fillna (среднее/медиана/ffill)?
  2. Чем map отличается от apply на Series и когда что быстрее?
  3. Как извлечь год из строки «2024-05-01» через str.extract?
  4. Почему после df.fillna(0) колонка может не измениться?

2 минуты письменно; соберите 1–2 ответа вслух.

Это мини-самооценка перед семинаром: запишите ответы, не подглядывая в слайды.

Семинар и ДЗ недели 5

Готовы? Откройте семинар-5.md и dirty_salary.csv и проверьте на себе шаги 5.1–5.5.

Сегодня вы научились: семь навыков

  1. Аудить качество: dtypes, isna().sum(), duplicated().sum().
  2. Обрабатывать пропуски с обоснованной стратегией (dropna/fillna).
  3. Применять правило «мутация vs копия» — присваивать результат.
  4. Находить/удалять дубликаты по ключу.
  5. Чинить типы: astype, to_numeric, to_datetime, нормализация число-строк.
  6. Чистить тексты: .str, map/apply, выбирать векторизацию.
  7. Ловить выбросы (IQR/z-score) и обосновывать решение.

One-minute paper: главное, что вы поняли, и один оставшийся вопрос.

Напишите one-minute paper: что было главным и какой вопрос остался неясным.