Въведение: Защо инженерингът на признаци е ключът към успешния ML модел
Ако сте прекарали достатъчно време в машинното обучение, вероятно вече сте го усетили на собствен гръб: качеството на признаците (features) определя горната граница на модела. Можете да натъпчете най-сложния алгоритъм — XGBoost, невронни мрежи, ансамбли — но ако входните признаци са шумни, нескалирани или просто неинформативни, резултатите ще бъдат разочароващи.
Честно казано, инженерингът на признаци е това, което отличава добрия ML инженер от отличния.
Накратко — това е процесът на трансформиране на суровите данни в числови представяния, които моделите реално могат да използват. Създаване на нови признаци, кодиране на категориални променливи, скалиране, извличане на информация от дати и текст, комбиниране на колони по смислен начин — всичко това влиза в тази категория.
В тази статия ще разгледаме конкретни техники с работещ код, използвайки Pandas 3.0 и Scikit-learn 1.6+ — двата основни инструмента за инженеринг на признаци в Python екосистемата през 2026. Ако вече сте минали през нашето ръководство за Scikit-Learn Pipeline и почистването на данни с Pandas 3.0, тази статия е логичната следваща стъпка.
Подготовка на средата и примерен набор от данни
Преди да се потопим в техниките, нека настроим средата. Ще създадем реалистичен набор от данни за цени на имоти, с който ще работим през цялата статия:
import pandas as pd
import numpy as np
from sklearn.model_selection import train_test_split
# Създаване на примерен набор от данни за прогнозиране на цени на имоти
np.random.seed(42)
n = 2000
data = pd.DataFrame({
"area_sqm": np.random.uniform(30, 250, n),
"rooms": np.random.choice([1, 2, 3, 4, 5], n, p=[0.1, 0.25, 0.35, 0.2, 0.1]),
"floor": np.random.randint(1, 21, n),
"total_floors": np.random.randint(3, 25, n),
"year_built": np.random.randint(1960, 2025, n),
"district": np.random.choice(
["Център", "Изток", "Запад", "Север", "Юг", "Студентски град", "Витоша"],
n
),
"heating": np.random.choice(["ТЕЦ", "Газ", "Електричество", "Камина"], n),
"has_parking": np.random.choice([True, False], n, p=[0.4, 0.6]),
"listing_date": pd.date_range("2023-01-01", periods=n, freq="12h"),
})
# Изчисляване на целева променлива (цена)
data["price"] = (
data["area_sqm"] * np.random.uniform(800, 1500, n)
+ data["rooms"] * 5000
+ np.where(data["district"] == "Център", 30000, 0)
+ np.random.normal(0, 10000, n)
)
# Добавяне на липсващи стойности (реалистичен сценарий)
data.loc[np.random.choice(n, 50, replace=False), "heating"] = np.nan
data.loc[np.random.choice(n, 30, replace=False), "year_built"] = np.nan
print(data.info())
print(data.head())
Набор от данни за имоти — класика в ML, но и достатъчно близък до реалността, за да бъдат примерите полезни.
Признаци от дати и времеви данни
Датите са едни от най-богатите източници на признаци, но сами по себе си са безполезни за модела. Трикът е да извлечете числова информация от тях.
Основни времеви признаци
# Извличане на времеви компоненти
data["listing_year"] = data["listing_date"].dt.year
data["listing_month"] = data["listing_date"].dt.month
data["listing_quarter"] = data["listing_date"].dt.quarter
data["listing_day_of_week"] = data["listing_date"].dt.dayofweek
data["is_weekend"] = data["listing_day_of_week"].isin([5, 6]).astype(int)
# Сезон (полезно за пазара на имоти)
data["season"] = data["listing_month"].map({
12: "Зима", 1: "Зима", 2: "Зима",
3: "Пролет", 4: "Пролет", 5: "Пролет",
6: "Лято", 7: "Лято", 8: "Лято",
9: "Есен", 10: "Есен", 11: "Есен"
})
print(data[["listing_date", "listing_month", "listing_quarter",
"listing_day_of_week", "is_weekend", "season"]].head(8))
Изчислени времеви признаци
# Възраст на сградата
current_year = 2026
data["building_age"] = current_year - data["year_built"]
# Дни от публикуване на обявата
reference_date = pd.Timestamp("2026-03-01")
data["days_since_listing"] = (reference_date - data["listing_date"]).dt.days
# Циклично кодиране на месец (за модели, чувствителни към разстояние)
data["month_sin"] = np.sin(2 * np.pi * data["listing_month"] / 12)
data["month_cos"] = np.cos(2 * np.pi * data["listing_month"] / 12)
Цикличното кодиране заслужава отделно внимание. Помислете за секунда — месец 12 (декември) и месец 1 (януари) са съседни, но числово разликата е 11. С sin и cos трансформацията тази цикличност се запазва правилно. Малка, но важна подробност, която лесно се пропуска.
Числови трансформации
Суровите числови стойности рядко са в идеалната форма за моделите. Различните трансформации помагат за нормализиране на разпределенията и за улавяне на нелинейни зависимости.
Логаритмични и степенни трансформации
# Логаритмична трансформация за данни с дясна асиметрия
data["log_area"] = np.log1p(data["area_sqm"])
data["log_price"] = np.log1p(data["price"])
# Квадратен корен — по-мека трансформация
data["sqrt_area"] = np.sqrt(data["area_sqm"])
# Използване на PowerTransformer от Scikit-learn
from sklearn.preprocessing import PowerTransformer
pt = PowerTransformer(method="yeo-johnson")
data["area_yeo_johnson"] = pt.fit_transform(data[["area_sqm"]])
Съвет от практиката: np.log1p вместо np.log — защото log(0) е минус безкрайност, а log1p(0) = 0. Дребна разлика, която спестява главоболия.
Бининг (дискретизация) на числови стойности
# Ръчни граници на бинове
data["area_bin"] = pd.cut(
data["area_sqm"],
bins=[0, 50, 80, 120, 180, 300],
labels=["Студио", "Малък", "Среден", "Голям", "Луксозен"]
)
# Квантилен бининг — равен брой наблюдения във всеки бин
data["price_quantile"] = pd.qcut(
data["price"], q=5,
labels=["Много нисък", "Нисък", "Среден", "Висок", "Много висок"]
)
# KBinsDiscretizer от Scikit-learn
from sklearn.preprocessing import KBinsDiscretizer
kbd = KBinsDiscretizer(n_bins=5, encode="ordinal", strategy="quantile")
data["area_kbd"] = kbd.fit_transform(data[["area_sqm"]])
Бинингът е особено полезен, когато подозирате, че зависимостта не е линейна. Вместо моделът да се опитва да улови нелинейност от една непрекъсната колона, му давате готови категории. Понякога простотата печели.
Кодиране на категориални променливи
Повечето ML модели работят само с числа. Точка. Затова категориалните данни (район, тип отопление и т.н.) трябва да бъдат преобразувани в числов формат. Но изборът на метод е доста по-важен, отколкото може би си мислите.
One-Hot кодиране
Най-простият и безопасен подход за номинални категории — тоест такива без естествена подредба:
from sklearn.preprocessing import OneHotEncoder
ohe = OneHotEncoder(sparse_output=False, drop="first", handle_unknown="ignore")
# Fit и transform
heating_encoded = ohe.fit_transform(data[["heating"]])
feature_names = ohe.get_feature_names_out(["heating"])
print(feature_names)
# ['heating_Електричество' 'heating_Камина' 'heating_ТЕЦ']
# Използване на Pandas get_dummies (по-бърз начин за EDA)
district_dummies = pd.get_dummies(data["district"], prefix="district", drop_first=True)
print(district_dummies.head())
Ординално кодиране
Когато категориите имат естествена подредба, ординалното кодиране запазва тази информация:
from sklearn.preprocessing import OrdinalEncoder
# Определяне на реда на категориите
oe = OrdinalEncoder(
categories=[["Студио", "Малък", "Среден", "Голям", "Луксозен"]],
handle_unknown="use_encoded_value",
unknown_value=-1
)
data["area_bin_encoded"] = oe.fit_transform(data[["area_bin"]])
print(data[["area_bin", "area_bin_encoded"]].drop_duplicates().sort_values("area_bin_encoded"))
Target Encoding с TargetEncoder (Scikit-learn 1.3+)
TargetEncoder е един от любимите ми методи. Той замества всяка категория със средната стойност на целевата променлива за тази категория. Особено полезен е при категориални признаци с висока кардиналност — много уникални стойности, където One-Hot кодирането би генерирало прекалено много колони.
from sklearn.preprocessing import TargetEncoder
te = TargetEncoder(smooth="auto", target_type="continuous")
# ВАЖНО: fit_transform използва вътрешен cross-fitting,
# за да предотврати изтичане на информация (data leakage)
X_train, X_test, y_train, y_test = train_test_split(
data[["district"]], data["price"], test_size=0.2, random_state=42
)
district_encoded_train = te.fit_transform(X_train, y_train)
district_encoded_test = te.transform(X_test)
print("Кодирани стойности за район (тренировъчни данни):")
print(pd.DataFrame(district_encoded_train, columns=["district_encoded"]).describe())
Внимание: При TargetEncoder, fit(X, y).transform(X) дава различен резултат от fit_transform(X, y). Защо? Защото fit_transform използва вътрешна крос-валидация за защита от преобучение. Това е капан, в който съм виждал да попадат дори опитни хора. Винаги използвайте fit_transform за тренировъчните данни.
Създаване на нови признаци чрез комбиниране
Тук идва наистина интересната част. Едни от най-информативните признаци не съществуват в оригиналните данни — те се получават чрез комбиниране на съществуващи колони. И точно тук домейн познанието прави огромна разлика.
Аритметични комбинации
# Цена на квадратен метър
data["price_per_sqm"] = data["price"] / data["area_sqm"]
# Средна площ на стая
data["avg_room_area"] = data["area_sqm"] / data["rooms"]
# Етаж спрямо общия брой етажи (относителна позиция)
data["floor_ratio"] = data["floor"] / data["total_floors"]
# Дали е последен етаж
data["is_top_floor"] = (data["floor"] == data["total_floors"]).astype(int)
# Дали е първи етаж
data["is_ground_floor"] = (data["floor"] == 1).astype(int)
# Комбинирани флагове
data["is_new_building"] = (data["building_age"] <= 5).astype(int)
data["is_large_apartment"] = ((data["area_sqm"] > 120) & (data["rooms"] >= 4)).astype(int)
print(data[["floor", "total_floors", "floor_ratio", "is_top_floor"]].head(10))
Обърнете внимание на floor_ratio — 5-ият етаж в 6-етажна сграда е нещо съвсем различно от 5-ия етаж в 20-етажна. Абсолютната стойност на етажа не казва цялата история.
Полиномиални и интерактивни признаци
from sklearn.preprocessing import PolynomialFeatures
# Създаване на полиномиални признаци от степен 2
poly = PolynomialFeatures(degree=2, interaction_only=False, include_bias=False)
numeric_features = data[["area_sqm", "rooms", "building_age"]].dropna()
poly_features = poly.fit_transform(numeric_features)
print(f"Оригинални признаци: {numeric_features.shape[1]}")
print(f"Полиномиални признаци: {poly_features.shape[1]}")
print("Имена:", poly.get_feature_names_out())
# ['area_sqm' 'rooms' 'building_age' 'area_sqm^2' 'area_sqm rooms'
# 'area_sqm building_age' 'rooms^2' 'rooms building_age' 'building_age^2']
Полиномиалните признаци улавят нелинейни зависимости, но внимавайте — броят им расте много бързо. От 3 входни признака получихме 9. При 10 входа и степен 2 ще имате 65 признака. За повечето практически задачи степен 2 е напълно достатъчна, не се изкушавайте да вдигате повече.
Скалиране на признаци
Много модели (линейна регресия, SVM, KNN, невронни мрежи) са чувствителни към мащаба на входните данни. Ако една колона е в диапазон 0–1, а друга в диапазон 0–1 000 000, моделът ще даде неоправдано голямо тегло на втората. Това е класически проблем и решението е просто — скалиране.
StandardScaler и MinMaxScaler
from sklearn.preprocessing import StandardScaler, MinMaxScaler, RobustScaler
# StandardScaler: средна стойност 0, стандартно отклонение 1
scaler_standard = StandardScaler()
data["area_standard"] = scaler_standard.fit_transform(data[["area_sqm"]])
# MinMaxScaler: стойности между 0 и 1
scaler_minmax = MinMaxScaler()
data["area_minmax"] = scaler_minmax.fit_transform(data[["area_sqm"]])
# RobustScaler: устойчив на извънредни стойности (outliers)
scaler_robust = RobustScaler()
data["area_robust"] = scaler_robust.fit_transform(data[["area_sqm"]])
print(data[["area_sqm", "area_standard", "area_minmax", "area_robust"]].describe())
Кога кой скалер да изберем:
- StandardScaler — когато данните са приблизително нормално разпределени. Това е вашият „подразбиращ се" вариант.
- MinMaxScaler — когато искате стойности в конкретен диапазон (напр. за невронни мрежи, които обикновено очакват вход между 0 и 1)
- RobustScaler — когато данните имат много извънредни стойности. Използва медиана и интерквартилен обхват вместо средна стойност и стандартно отклонение, така че outliers не го разбиват.
Важно: Дървовидните модели (Random Forest, XGBoost, LightGBM) не се нуждаят от скалиране. Те разделят данните по прагови стойности и мащабът не влияе на резултата. Ако използвате само дървовидни модели, спестете си тази стъпка.
Изграждане на пълен пайплайн с ColumnTransformer
Добре, ето къде всичко се свързва в едно цяло. В реални проекти различните типове колони изискват различни трансформации и ColumnTransformer от Scikit-learn ви позволява да приложите точно правилната трансформация към всяка колона.
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import (
StandardScaler, OneHotEncoder, OrdinalEncoder, TargetEncoder
)
from sklearn.ensemble import GradientBoostingRegressor
# Дефиниране на групи колони
numeric_features = ["area_sqm", "rooms", "floor", "total_floors", "building_age"]
categorical_low_card = ["heating", "has_parking"] # Ниска кардиналност
categorical_high_card = ["district"] # Висока кардиналност
# Пайплайн за числови признаци
numeric_transformer = Pipeline(steps=[
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler())
])
# Пайплайн за категориални признаци с ниска кардиналност
categorical_low_transformer = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(drop="first", handle_unknown="ignore", sparse_output=False))
])
# Пайплайн за категориални признаци с висока кардиналност
categorical_high_transformer = Pipeline(steps=[
("imputer", SimpleImputer(strategy="most_frequent")),
("target_enc", TargetEncoder(smooth="auto"))
])
# Обединяване с ColumnTransformer
preprocessor = ColumnTransformer(
transformers=[
("num", numeric_transformer, numeric_features),
("cat_low", categorical_low_transformer, categorical_low_card),
("cat_high", categorical_high_transformer, categorical_high_card),
],
verbose_feature_names_out=True
)
# Пълен пайплайн: предварителна обработка + модел
full_pipeline = Pipeline(steps=[
("preprocessor", preprocessor),
("model", GradientBoostingRegressor(n_estimators=200, random_state=42))
])
# Подготовка на данните
X = data[numeric_features + categorical_low_card + categorical_high_card]
y = data["price"]
X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)
# Обучение и оценка
full_pipeline.fit(X_train, y_train)
score = full_pipeline.score(X_test, y_test)
print(f"R² на тестовия набор: {score:.4f}")
Красотата на този подход е, че целият процес — от запълване на липсващи стойности до обучение на модела — е капсулиран в един обект. Можете да го сериализирате с joblib и да го заредите в продукция без да се притеснявате, че сте пропуснали някоя стъпка.
Извеждане на резултати като DataFrame с set_output
От Scikit-learn 1.2+ можете да получите DataFrame вместо NumPy масив, което е изключително удобно за дебъгване:
# Активиране на Pandas изход
preprocessor.set_output(transform="pandas")
# Трансформиране
X_transformed = preprocessor.fit_transform(X_train, y_train)
print(X_transformed.head())
print(f"Тип: {type(X_transformed)}")
#
print(f"Колони: {list(X_transformed.columns)}")
Тази функционалност е безценна при отстраняване на грешки. Вместо да гадаете коя колона е на каква позиция в NumPy масива, получавате чист DataFrame с имена. В Scikit-learn 1.6 параметърът verbose_feature_names_out в ColumnTransformer приема callable или форматиращ низ, което дава още повече контрол върху именуването.
Селекция на признаци: Кои да запазим?
Създаването на много признаци е само половината от работата. И тук много хора спират, което е грешка. Твърде много признаци водят до преобучение, бавно обучение и по-трудна интерпретация. Селекцията на признаци помага да запазите само наистина полезните.
Корелационен анализ
# Корелация с целевата променлива
numeric_cols = data.select_dtypes(include=[np.number]).columns
correlations = data[numeric_cols].corrwith(data["price"]).abs().sort_values(ascending=False)
print("Топ 10 признаци по корелация с цената:")
print(correlations.head(10))
Бърз и лесен начин да получите първоначална представа. Имайте предвид обаче, че корелацията улавя само линейни зависимости — признак с ниска корелация може да е изключително полезен за дървовиден модел.
Автоматична селекция с Scikit-learn
from sklearn.feature_selection import (
VarianceThreshold, SelectKBest, f_regression, mutual_info_regression
)
# 1. Премахване на признаци с ниска дисперсия
selector_var = VarianceThreshold(threshold=0.01)
# 2. Селекция по статистически тест (F-test)
selector_f = SelectKBest(score_func=f_regression, k=10)
# 3. Селекция по взаимна информация (по-мощен метод)
selector_mi = SelectKBest(score_func=mutual_info_regression, k=10)
# Пример: Mutual Information селекция
X_numeric = data[numeric_cols].drop(columns=["price"]).dropna()
y_aligned = data.loc[X_numeric.index, "price"]
selector_mi.fit(X_numeric, y_aligned)
scores = pd.Series(selector_mi.scores_, index=X_numeric.columns)
print("\nВзаимна информация (top 10):")
print(scores.sort_values(ascending=False).head(10))
Лично аз почти винаги предпочитам Mutual Information пред F-test. Причината е проста — MI улавя както линейни, така и нелинейни зависимости, докато F-test работи само с линейни. Разликата в изчислителното време е минимална.
Рекурсивно елиминиране на признаци (RFE)
from sklearn.feature_selection import RFECV
from sklearn.ensemble import RandomForestRegressor
# RFE с крос-валидация
rfe = RFECV(
estimator=RandomForestRegressor(n_estimators=100, random_state=42, n_jobs=-1),
step=1,
cv=5,
scoring="r2",
min_features_to_select=5
)
rfe.fit(X_numeric, y_aligned)
selected = X_numeric.columns[rfe.support_]
print(f"\nИзбрани признаци ({len(selected)}):")
print(list(selected))
print(f"Оптимален брой признаци: {rfe.n_features_}")
RFECV е по-бавен (крос-валидация с рекурсивно премахване на признаци не е евтино), но дава много надежден резултат. Ако имате достатъчно време и не работите с огромен набор от данни, струва си да го изпробвате.
Практически съвети и чести грешки
Предотвратяване на изтичане на данни (Data Leakage)
Ако трябва да запомните само едно нещо от тази статия, нека бъде това. Data leakage е най-честата и най-коварната грешка при инженеринга на признаци:
- Скалиране преди разделяне: Ако приложите
fit_transformвърху целия набор от данни и после го разделите — скалерът вече е „видял" тестовите данни. Решение: винаги разделяйте данните преди всякакви трансформации. - Target Encoding без крос-валидация: Кодирането на тренировъчните данни с тяхната собствена средна стойност на целевата променлива води до преобучение. Решение: използвайте
TargetEncoderс вградения cross-fitting. - Използване на бъдеща информация: Ако прогнозирате цени за март, не използвайте данни от април. Звучи очевидно, но в практиката е изненадващо лесно да се допусне.
Кога какви техники да използвате
Различните модели се възползват от различни трансформации и няма универсален подход:
- Линейни модели (Linear Regression, SVM, KNN): Задължително скалиране, полиномиални признаци, One-Hot кодиране
- Дървовидни модели (Random Forest, XGBoost, LightGBM): Скалиране не е необходимо; Target Encoding и Ordinal Encoding работят чудесно; полиномиални признаци обикновено не помагат
- Невронни мрежи: Скалиране до [0,1] или [-1,1]; Entity Embeddings за категориални данни
Итеративен подход
Инженерингът на признаци не е нещо, което правите веднъж и забравяте. Започнете с базов модел и основни признаци, добавяйте нови признаци постепенно и следете дали те подобряват резултата чрез крос-валидация.
И не се страхувайте да премахвате признаци. По-простият модел с по-малко, но добре подбрани признаци често превъзхожда сложния с десетки полуполезни колони. Това е нещо, което идва с опита, но го казвам, за да не се чувствате виновни, когато изтривате признаци, в които сте вложили време.
Често задавани въпроси (FAQ)
Каква е разликата между инженеринг на признаци и селекция на признаци?
Инженерингът на признаци (Feature Engineering) е процесът на създаване на нови признаци от суровите данни — например извличане на месец от дата или изчисляване на цена на квадратен метър. Селекцията на признаци (Feature Selection) е процесът на избиране на подмножество от съществуващите признаци, които са най-информативни за модела. На практика двата процеса вървят ръка за ръка: първо създавате, после избирате най-добрите.
Трябва ли да скалирам признаците за дървовидни модели като Random Forest и XGBoost?
Не, дървовидните модели не се влияят от мащаба на входните данни, защото разделят данните по прагови стойности. Скалирането е необходимо за линейни модели, SVM, KNN и невронни мрежи. Но ако вече скалирате за други модели в ColumnTransformer, няма вреда да го приложите и за дървовидни — просто няма да направи разлика.
Как да избера между One-Hot Encoding и Target Encoding?
Правилото е просто: One-Hot за категории с малък брой уникални стойности (до 10–15). За признаци с висока кардиналност (стотици или хиляди уникални стойности — пощенски кодове, ID на продукти) One-Hot ще създаде огромен брой колони и ще забави модела. В такъв случай Target Encoding е по-подходящ, но не забравяйте за data leakage — затова използвайте TargetEncoder от Scikit-learn с вградената защита.
Какви признаци да създам, ако нямам домейн познания?
Започнете с универсалните техники: извлечете компоненти от дати, изчислете съотношения между числови колони, създайте бинарни флагове от условия, приложете логаритмична трансформация на колони с дясна асиметрия. После пуснете PolynomialFeatures за интерактивни признаци и SelectKBest или RFECV за филтриране. Библиотеката Feature-engine също предлага готови трансформатори, които могат да ви спестят доста писане на код.
Как да предотвратя Data Leakage при инженеринг на признаци?
Основното правило: всички трансформации трябва да бъдат „обучени" (fit) само върху тренировъчните данни. Използвайте Pipeline и ColumnTransformer от Scikit-learn — те автоматично прилагат fit_transform само върху тренировъчния набор и transform върху тестовия. Никога не скалирайте или кодирайте данните преди разделянето. Ако спазвате това правило и работите с пайплайни, шансовете за leakage намаляват драстично.