Въведение: Защо почистването на данни отнема толкова време
Ако се занимавате с анализ на данни или машинно обучение, вероятно вече знаете тази неудобна истина — около 70–80% от времето на всеки дейта специалист отива за почистване и подготовка на данни. Не за строене на модели. Не за красиви визуализации. А за справяне с липсващи стойности, дублирани редове, грешни типове данни и безкрайни несъответствия във форматите.
Честно казано, когато за пръв път чух тази статистика, не повярвах. После прекарах три дни в опити да обединя два CSV файла с различни кодировки и се убедих лично.
С излизането на Pandas 3.0 през януари 2026 г. обаче нещата се промениха доста. Новият стринг тип по подразбиране, Copy-on-Write режимът като стандарт и унифицираната обработка на null стойности правят почистването на данни по-предсказуемо и по-бързо от всякога. В това ръководство ще минем през всичко — от първоначалния анализ до изграждането на автоматизиран пайплайн за почистване.
Какво ново носи Pandas 3.0 за почистването на данни
Нов стринг тип по подразбиране
Една от най-значимите промени в Pandas 3.0 е въвеждането на специализиран str тип данни по подразбиране. Преди версия 3.0 текстовите колони се съхраняваха като object тип, който можеше да съдържа буквално всичко — стрингове, числа, None, дори списъци (да, списъци в клетка на DataFrame — виждал съм го). Сега Pandas автоматично разпознава текстовите колони и им задава тип str, подкрепен от PyArrow, ако е инсталиран.
import pandas as pd
# В Pandas 3.0 текстовите колони автоматично получават str тип
df = pd.DataFrame({
"име": ["Иван", "Мария", "Петър", None],
"град": ["София", "Пловдив", None, "Варна"],
"възраст": [25, 30, 35, 28]
})
print(df.dtypes)
# име str
# град str
# възраст int64
# dtype: object
# Стринг колоните вече приемат САМО стрингове или NaN
# Това предотвратява неочаквани миксове от типове
Copy-on-Write като стандарт
В Pandas 3.0 Copy-on-Write (CoW) вече е режимът по подразбиране. На практика това означава, че когато правите подмножество на DataFrame, данните не се копират веднага — копирането се случва едва при опит за модификация. Това е особено важно при почистването на данни, където постоянно създавате филтрирани и трансформирани версии.
import pandas as pd
df = pd.DataFrame({
"продукт": ["Лаптоп", "Телефон", "Таблет", "Лаптоп"],
"цена": [1500.0, 800.0, 450.0, 1500.0],
"наличност": [True, False, True, True]
})
# Това НЕ копира данните (само view)
df_активни = df[df["наличност"] == True]
# Копирането се случва едва при модификация
df_активни["цена"] = df_активни["цена"] * 0.9
# Оригиналният DataFrame остава непроменен
print(df["цена"].tolist()) # [1500.0, 800.0, 450.0, 1500.0]
Ако сте работили с по-стари версии на Pandas, вероятно си спомняте прословутото предупреждение SettingWithCopyWarning. Е, с CoW то вече е минало.
Унифицирана обработка на null стойности
Pandas 3.0 обединява начина, по който се третират None, np.nan и pd.NA. Вече няма объркване кой sentinel се използва за кой тип данни — NaN (np.nan) е стандартният маркер за липсващи стойности навсякъде. Това значително опростява логиката при почистване.
Стъпка 1: Проучване на данните преди почистване
Преди да хванете ножиците и да започнете да режете, първо трябва да разберете какво имате. Проучването на данните е фундаменталната първа стъпка, която доста начинаещи пропускат — и после плащат цената с часове загубено време.
Аз лично винаги започвам с този блок код:
import pandas as pd
import numpy as np
# Зареждане на примерен набор от данни
df = pd.read_csv("продажби_2026.csv")
# Бърз преглед на структурата
print(f"Размер: {df.shape[0]} реда × {df.shape[1]} колони")
print(f"\nТипове данни:\n{df.dtypes}")
print(f"\nПърви 5 реда:\n{df.head()}")
# Статистическо обобщение
print(f"\nОписание на числовите колони:\n{df.describe()}")
# Обобщение на липсващите стойности
липсващи = df.isnull().sum()
процент_липсващи = (липсващи / len(df)) * 100
обобщение = pd.DataFrame({
"Липсващи": липсващи,
"Процент": процент_липсващи.round(2)
}).sort_values("Процент", ascending=False)
print(f"\nЛипсващи стойности:\n{обобщение[обобщение['Липсващи'] > 0]}")
Няколко ключови неща, на които да обърнете внимание:
- Типове данни: Дали числовите колони наистина са числови? Дали датите са разпознати като дати?
- Липсващи стойности: Колко са и в кои колони? Случайно ли са разпределени или следват модел?
- Диапазон на стойностите: Има ли отрицателни цени? Възраст от 999? Дати от бъдещето?
- Брой уникални стойности: Дали категорийна колона с 5 очаквани категории всъщност има 50 заради правописни грешки?
# Проверка за уникални стойности в категорийни колони
for col in df.select_dtypes(include=["str", "object"]).columns:
уникални = df[col].nunique()
print(f"{col}: {уникални} уникални стойности")
if уникални < 20:
print(f" → {df[col].value_counts().head(10).to_dict()}")
print()
Тази проверка ме е спасявала безброй пъти. Веднъж открих, че колона „статус" с очаквани 3 стойности имаше 47 варианта — заради интервали, главни букви и правописни грешки.
Стъпка 2: Обработка на липсващи стойности
Липсващите стойности са може би най-честият проблем при работа с реални данни. Те идват от неизпълнени анкети, повредени сензори, системни грешки или просто непълно въвеждане. Важният въпрос тук не е „как да ги премахна", а „каква е причината и коя стратегия е подходяща".
Стратегия 1: Премахване на редове или колони
Подходяща когато липсващите стойности са малко (под 5%) и са случайно разпределени.
# Премахване на редове с липсващи стойности
df_чист = df.dropna()
# Премахване само на редове, където конкретна колона е празна
df_чист = df.dropna(subset=["имейл", "телефон"])
# Премахване на колони, където повече от 50% от стойностите липсват
праг = len(df) * 0.5
df_чист = df.dropna(axis=1, thresh=int(праг))
Внимание: ако просто изтриете всички редове с липсващи стойности, рискувате да загубите огромна част от данните си. При набор от 100 000 реда, където 30% имат поне една празна клетка, dropna() може да ви остави с 70 000 реда. Понякога това е приемливо, друг път — категорично не.
Стратегия 2: Попълване със статистически стойности
Когато данните липсват случайно и имате достатъчно наблюдения, можете да попълните с мярка за централна тенденция.
# Попълване на числови колони с медианата
числови_колони = df.select_dtypes(include=["number"]).columns
for col in числови_колони:
медиана = df[col].median()
df[col] = df[col].fillna(медиана)
# Попълване на категорийни колони с модата (най-честата стойност)
категорийни_колони = df.select_dtypes(include=["str", "object"]).columns
for col in категорийни_колони:
мода = df[col].mode()[0]
df[col] = df[col].fillna(мода)
Стратегия 3: Попълване по групи
Понякога средната стойност за цялата колона не е достатъчно добра. Ето типичен пример — средната заплата в цялата компания не е подходяща за попълване на липсваща заплата на програмист. По-добре е да ползвате средната заплата конкретно на програмистите.
# Попълване на заплатата с медианата за съответния отдел
df["заплата"] = df.groupby("отдел")["заплата"].transform(
lambda x: x.fillna(x.median())
)
# За оставащите NaN (ако цял отдел е празен), попълваме с общата медиана
df["заплата"] = df["заплата"].fillna(df["заплата"].median())
Този подход е много по-точен и аз лично го предпочитам пред глобалното попълване с медиана.
Стратегия 4: Интерполация за времеви серии
При данни с времева компонента линейната интерполация дава доста по-реалистични стойности от простата медиана.
# Линейна интерполация за времеви серии
df = df.sort_values("дата")
df["температура"] = df["температура"].interpolate(method="linear")
# За по-сложни модели
df["продажби"] = df["продажби"].interpolate(method="spline", order=3)
Стъпка 3: Премахване на дублирани записи
Дублираните записи могат сериозно да изкривят анализа ви — от надути статистики до модели, които дават прекалено голяма тежест на определени наблюдения. Източниците на дублиране са разнообразни: многократно изпращане на форма, грешки при обединяване на таблици, повторно зареждане на данни.
Хубавото е, че откриването им е доста лесно:
# Проверка за пълни дублирани редове
дублирани = df.duplicated().sum()
print(f"Пълни дублирани редове: {дублирани}")
# Проверка за дубликати по конкретни колони
дублирани_по_имейл = df.duplicated(subset=["имейл"]).sum()
print(f"Дублирани имейли: {дублирани_по_имейл}")
# Преглед на дублиратите преди премахване
print(df[df.duplicated(subset=["имейл"], keep=False)]
.sort_values("имейл"))
# Премахване, запазвайки последния запис
df = df.drop_duplicates(subset=["имейл"], keep="last")
# За по-сложни сценарии — премахване по множество колони
df = df.drop_duplicates(
subset=["име", "фамилия", "дата_на_раждане"],
keep="first"
)
Един съвет: винаги преглеждайте дублиратите преди да ги изтриете. Понякога „дубликатът" всъщност е валиден запис — например клиент с два отделни акаунта.
Стъпка 4: Коригиране на типове данни
Грешните типове данни са тих убиец на анализа. Числова колона, съхранена като текст, няма да даде правилна сума. Дата, записана като стринг, не може да се филтрира по месеци. Pandas 3.0 е доста по-добър в автоматичното разпознаване, но все пак си заслужава да проверите.
# Конвертиране на дати
df["дата_на_поръчка"] = pd.to_datetime(
df["дата_на_поръчка"],
format="%d.%m.%Y",
errors="coerce" # Невалидните дати стават NaT
)
# Конвертиране на числови колони
df["цена"] = pd.to_numeric(df["цена"], errors="coerce")
# Конвертиране на булеви стойности
df["активен"] = df["активен"].map({"да": True, "не": False, "yes": True, "no": False})
# Използване на категориен тип за колони с малко уникални стойности
df["регион"] = df["регион"].astype("category")
df["статус"] = df["статус"].astype("category")
# Проверка на резултата
print(df.dtypes)
print(f"\nПамет преди: {df.memory_usage(deep=True).sum() / 1024**2:.2f} MB")
Между другото, errors="coerce" е изключително полезен параметър. Вместо да хвърля грешка при невалидна стойност, просто я превръща в NaN (или NaT за дати). Така можете първо да конвертирате, а после да се заемете с проблемните стойности.
Стъпка 5: Стандартизиране на текстови данни
Текстовите данни са особено проблематични. „София", „софия", „СОФИЯ" и „ София " (с водещ интервал) трябва да се третират като една и съща стойност. С новия str тип в Pandas 3.0, стринговите операции са по-бързи и по-надеждни.
# Почистване на текстови колони
df["град"] = (
df["град"]
.str.strip() # Премахване на водещи/заключителни интервали
.str.lower() # Привеждане в малки букви
.str.replace(r"\s+", " ", regex=True) # Множество интервали → един
)
# Стандартизиране на конкретни стойности
замени = {
"сф": "софия",
"плд": "пловдив",
"пловдив.": "пловдив",
"varna": "варна"
}
df["град"] = df["град"].replace(замени)
# Почистване на имейли
df["имейл"] = (
df["имейл"]
.str.strip()
.str.lower()
)
# Премахване на специални символи от телефонни номера
df["телефон"] = df["телефон"].str.replace(r"[^\d+]", "", regex=True)
Почистване с регулярни изрази
За по-сложни случаи регулярните изрази са незаменим инструмент. Да, синтаксисът им изглежда като шифрован код (дори след години все още се налага да проверявам в документацията), но за определени задачи просто няма по-добро решение.
import re
# Извличане на числа от смесен текст
df["площ_м2"] = df["описание"].str.extract(r"(\d+[\.,]?\d*)\s*(?:кв\.?\s*м|m2|м2)")
df["площ_м2"] = pd.to_numeric(
df["площ_м2"].str.replace(",", "."),
errors="coerce"
)
# Валидиране на имейл формат
имейл_модел = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
df["валиден_имейл"] = df["имейл"].str.match(имейл_модел, na=False)
Стъпка 6: Откриване и обработка на аномалии (outliers)
Аномалиите са стойности, които се отклоняват значително от останалите. Те могат да бъдат истински екстремни наблюдения (рядък, но реален случай) или грешки при въвеждане (заплата от 1 000 000 вместо 10 000). Разграничаването между двете е критично — и, за съжаление, не винаги лесно.
Метод 1: Интерквартилен диапазон (IQR)
def открий_аномалии_iqr(df, колона, множител=1.5):
"""Открива аномалии чрез IQR метод."""
Q1 = df[колона].quantile(0.25)
Q3 = df[колона].quantile(0.75)
IQR = Q3 - Q1
долна_граница = Q1 - множител * IQR
горна_граница = Q3 + множител * IQR
аномалии = df[
(df[колона] < долна_граница) | (df[колона] > горна_граница)
]
print(f"{колона}: {len(аномалии)} аномалии от {len(df)} записа")
print(f" Граници: [{долна_граница:.2f}, {горна_граница:.2f}]")
return аномалии
# Използване
аномалии_цена = открий_аномалии_iqr(df, "цена")
аномалии_заплата = открий_аномалии_iqr(df, "заплата", множител=2.0)
Метод 2: Z-score
from scipy import stats
# Изчисляване на z-score за числовите колони
числови = df.select_dtypes(include=["number"]).columns
z_scores = df[числови].apply(stats.zscore, nan_policy="omit")
# Маркиране на стойности с |z| > 3
аномалии_маска = (z_scores.abs() > 3).any(axis=1)
print(f"Редове с аномалии (z > 3): {аномалии_маска.sum()}")
Кой метод да изберете? IQR е по-устойчив на екстремни стойности и работи добре с изкривени разпределения. Z-score предполага нормално разпределение, така че е по-подходящ за данни, които приблизително следват камбановидната крива.
Стратегии за обработка
# Вариант 1: Премахване на аномалиите
df_без_аномалии = df[~аномалии_маска]
# Вариант 2: Ограничаване (capping/winsorization)
долен_перцентил = df["цена"].quantile(0.01)
горен_перцентил = df["цена"].quantile(0.99)
df["цена_ограничена"] = df["цена"].clip(
lower=долен_перцентил,
upper=горен_перцентил
)
# Вариант 3: Заместване с NaN и после попълване
df.loc[аномалии_маска, "заплата"] = np.nan
df["заплата"] = df.groupby("отдел")["заплата"].transform(
lambda x: x.fillna(x.median())
)
Стъпка 7: Валидиране на данните
След всяка стъпка от почистването е важно да валидирате резултатите. Не просто приемайте, че всичко е наред — проверете го. Ето един прост, но ефективен подход:
def валидирай_данни(df, правила):
"""Прилага набор от валидационни правила върху DataFrame."""
резултати = {}
for име, правило in правила.items():
нарушения = df[~правило(df)]
резултати[име] = {
"нарушения": len(нарушения),
"процент": round(len(нарушения) / len(df) * 100, 2)
}
if len(нарушения) > 0:
print(f"⚠ {име}: {len(нарушения)} нарушения ({резултати[име]['процент']}%)")
else:
print(f"✓ {име}: OK")
return резултати
# Дефиниране на правила
правила = {
"Цена е положителна": lambda df: df["цена"] > 0,
"Възраст в диапазон 0-120": lambda df: df["възраст"].between(0, 120),
"Имейл е попълнен": lambda df: df["имейл"].notna(),
"Дата не е в бъдещето": lambda df: df["дата"] <= pd.Timestamp.now(),
}
валидирай_данни(df, правила)
Изграждане на автоматизиран пайплайн за почистване
Добре, стигнахме до най-интересната част. Вместо да повтаряте ръчно всяка стъпка за всеки нов набор от данни, по-разумно е да изградите преизползваем пайплайн. Ето как може да изглежда един такъв с Pandas 3.0:
import pandas as pd
import numpy as np
from datetime import datetime
def стъпка_типове(df):
"""Коригира типовете данни."""
за_дати = [col for col in df.columns if "дата" in col.lower() or "date" in col.lower()]
for col in за_дати:
df[col] = pd.to_datetime(df[col], errors="coerce")
за_числа = [col for col in df.columns if "цена" in col.lower() or "сума" in col.lower()]
for col in за_числа:
df[col] = pd.to_numeric(
df[col].astype(str).str.replace(",", ".").str.replace(r"[^\d.]", "", regex=True),
errors="coerce"
)
return df
def стъпка_текст(df):
"""Почиства текстовите колони."""
текстови = df.select_dtypes(include=["str", "object"]).columns
for col in текстови:
df[col] = df[col].str.strip().str.replace(r"\s+", " ", regex=True)
return df
def стъпка_дубликати(df, ключови_колони=None):
"""Премахва дублирани записи."""
преди = len(df)
if ключови_колони:
df = df.drop_duplicates(subset=ключови_колони, keep="last")
else:
df = df.drop_duplicates(keep="last")
след = len(df)
print(f"Премахнати дубликати: {преди - след}")
return df
def стъпка_липсващи(df, стратегия="медиана"):
"""Обработва липсващите стойности."""
числови = df.select_dtypes(include=["number"]).columns
текстови = df.select_dtypes(include=["str", "object"]).columns
if стратегия == "медиана":
for col in числови:
df[col] = df[col].fillna(df[col].median())
elif стратегия == "средна":
for col in числови:
df[col] = df[col].fillna(df[col].mean())
for col in текстови:
df[col] = df[col].fillna("неизвестно")
return df
def почисти_данни(път_до_файл, ключови_колони=None):
"""Главен пайплайн за почистване на данни."""
print(f"Зареждане: {път_до_файл}")
df = pd.read_csv(път_до_файл)
print(f"Начален размер: {df.shape}")
df = стъпка_типове(df)
df = стъпка_текст(df)
df = стъпка_дубликати(df, ключови_колони)
df = стъпка_липсващи(df)
print(f"Краен размер: {df.shape}")
return df
# Използване
df_чист = почисти_данни("продажби_2026.csv", ключови_колони=["поръчка_id"])
Красотата на този подход е, че можете лесно да добавяте или премахвате стъпки, да променяте параметрите и да го прилагате върху различни набори от данни без да пренаписвате код.
Практически съвети от реалния свят
- Винаги пазете оригинала: Никога не модифицирайте оригиналните данни директно. Работете върху копие или записвайте почистения файл отделно. Бъдещото ви „аз" ще ви благодари.
- Документирайте всяка стъпка: Записвайте какво сте променили и защо. Дори кратък коментар в кода е по-добре от нищо.
- Разделяйте преди почистване при ML: Ако подготвяте данни за модел, първо разделете на train/test набори. Почистването на целия набор може да доведе до изтичане на данни (data leakage) — а това е грешка, която е трудно да се открие после.
- Използвайте
pd.Categoricalза категории: За колони с малко уникални стойности (пол, статус, регион) конвертирането в категориен тип спестява памет и ускорява операциите. - Не изтривайте — маркирайте: Вместо да изтривате аномалии, създайте колона-флаг (
is_outlier). Така запазвате данните за евентуален бъдещ анализ.
Често задавани въпроси
Каква е разликата между dropna() и fillna() в Pandas?
dropna() премахва редовете (или колоните), които съдържат липсващи стойности, докато fillna() ги заменя с определена стойност — средна, медиана, конкретна стойност или резултат от интерполация. Ако липсващите данни са малко и случайно разпределени, dropna() е безопасен вариант. Ако загубата на редове е неприемлива обаче, fillna() запазва размера на набора от данни.
Как да разбера дали аномалия е грешка или реална стойност?
Няма универсален отговор и това е едно от нещата, които правят почистването на данни толкова трудно. Заплата от 500 000 лв. може да е грешка за редови служител, но напълно реална за изпълнителен директор. Препоръчителният подход е: първо използвайте статистически методи (IQR или z-score) за идентификация, после се консултирайте с експерт от съответната област, и накрая документирайте решението си. Ако не сте сигурни, маркирайте стойността с флаг, вместо да я изтривате.
Кога да използвам интерполация вместо попълване с медиана?
Интерполацията е подходяща когато данните имат подредба или времева компонента — например температурни измервания, цени на акции или месечни продажби. Медианата работи по-добре за данни без ясна подредба, като възраст на клиенти или тегло на продукти. При времеви серии с плавна тенденция линейната интерполация (method="linear") дава много по-реалистични резултати.
Какви са промените в Pandas 3.0 спрямо 2.x за почистване на данни?
Трите най-важни промени са: (1) нов str тип по подразбиране за текстови колони, който предотвратява миксове от типове; (2) Copy-on-Write като стандартен режим, който елиминира неочаквани мутации на данни; и (3) унифицирана обработка на null стойности с NaN като единствен маркер. Тези промени правят почистването доста по-предсказуемо, но имайте предвид, че стар код, който разчита на object тип или инплейс модификации, ще трябва да се актуализира.
Как да автоматизирам почистването на данни за повтарящи се задачи?
Най-добрият подход е да изградите модулен пайплайн от функции, всяка от които отговаря за една стъпка (типове, текст, дубликати, липсващи стойности). Можете да комбинирате тези функции с инструменти като scikit-learn Pipeline за ML сценарии или Apache Airflow за ETL процеси. За валидация погледнете библиотеки като Great Expectations или pandera, които позволяват декларативно дефиниране на правила за качество на данните.