Python, de la zero Lecția 50 / 60

Feature engineering: partea care conteaza cel mai mult

Transformarile care fac din date brute combustibil pentru model si cele care scurg in tacere informatii din viitor.

Există un adevăr tăcut în machine learning-ul tabular peste care majoritatea cursurilor trec: modelul rareori contează la fel de mult ca feature-urile. Poți lua același set de date, să-i arunci aceiași gradient-boosted trees de două ori și o versiune o bate pe cealaltă cu zece puncte procentuale pur și simplu fiindcă cineva și-a petrecut un weekend gândindu-se la ce să pună pe partea de input. Acel weekend e feature engineering și asta e lecția care, mai mult decât oricare alta din acest modul, îți va mișca numerele.

Vom parcurge categoriile de feature engineering, apoi vom vorbi despre leakage, bug-ul care îți umflă în tăcere metricile și îți distruge producția. Majoritatea codului efectiv trăiește în pipeline-uri pe care le-ai văzut deja în lecția 49; partea grea e gândirea.

Ce înseamnă cu adevărat „engineering” aici

Feature engineering e actul de a transforma coloane brute în reprezentări din care un model poate învăța. Unele transformări sunt mecanice (centrare și scalare). Unele cer cunoștințe de domeniu (să știi că o tranzacție la 3 dimineața e mai suspectă decât una la 3 după-amiaza). Unele sunt pur și simplu inteligente (codarea momentului zilei ca sin(2*pi*hour/24) și cos(2*pi*hour/24) ca modelul să vadă că 23:00 e aproape de 00:00).

Categoriile pe care le voi parcurge:

  1. Scaling
  2. Codarea variabilelor categorice
  3. Interacțiuni
  4. Feature-uri de timp
  5. Agregări
  6. Feature-uri de text
  7. Tratarea valorilor lipsă

Și apoi secțiunea despre leakage, care e cea mai importantă parte a acestei lecții și partea pe care majoritatea tutorialelor grăbite o sar.

Scaling

Majoritatea modelelor non-tree țin la scala input-urilor tale. Modelele liniare, neural networks, k-nearest neighbors și SVM-urile toate fac mai prost, uneori catastrofal de prost, când o feature variază de la 0 la 1 și alta de la 0 la 1.000.000. Soluția e să standardizezi fiecare coloană numerică la medie 0 și abatere standard 1:

from sklearn.preprocessing import StandardScaler

scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
X_test_scaled = scaler.transform(X_test)

Modelele tree-based, random forests, XGBoost, LightGBM, nu țin la asta. Ele împart pe praguri, iar un prag pe o valoare înmulțită cu 1000 e același cu un prag pe valoarea însăși. Nu pierde timp scalând pentru trees.

Două variante de știut. MinMaxScaler rescalează la [0, 1], ceea ce vrei pentru input-uri în neural networks cu funcții de activare mărginite. RobustScaler folosește mediana și intervalul intercuartil, ceea ce vrei când datele tale au outlieri, o singură valoare mare nu va trage media și nu va umfla abaterea standard.

Pentru feature-uri numerice cu coadă foarte lungă (income, sume de tranzacții, page views), aplică mai întâi un log transform: np.log1p(x), adică log(1+x), care tratează zerourile elegant. După log, distribuția e mai aproape de simetrică și scalarea face ce trebuie.

Codarea variabilelor categorice

Categoriile trebuie să devină numere. Codarea potrivită depinde de cardinalitate și dacă există ordine.

Cardinalitate joasă, fără ordine: one-hot encoding. Fiecare categorie devine propria coloană 0/1.

from sklearn.preprocessing import OneHotEncoder

encoder = OneHotEncoder(handle_unknown="ignore", sparse_output=False)
X_encoded = encoder.fit_transform(X[["country", "plan"]])

handle_unknown="ignore" e ce te salvează în producție când apare o categorie nouă care nu era în datele de training. Fără el, serviciul tău crapă în momentul în care marketingul lansează într-o țară nouă.

Cardinalitate înaltă: nu face one-hot. Dacă ai 50.000 de user IDs unice sau 10.000 de SKU-uri de produs, one-hot îți dă o matrice rară de 50.000 de coloane pe care majoritatea modelelor o îneacă. Cele două opțiuni reale:

  • Target encoding: înlocuiește fiecare categorie cu media variabilei target pentru acea categorie. Deci dacă ai o coloană country și rata medie de conversie pentru utilizatorii din Italia e 4,2%, atunci country=IT devine 0.042. Puternic, dar o capcană de leakage (mai multe mai jos). Folosește biblioteca category_encoders sau TargetEncoder din sklearn (adăugat în 1.3, cu cross-fitting corect ca să eviți leakage-ul).
  • Hashing: trece categoria printr-o funcție de hash modulo n, ajungând la n coloane. Rapid, cu pierdere, fără leakage. Bun pentru cardinalitate foarte mare unde nu îți pasă de identitatea exactă a categoriei.

Categorii ordonate: ordinal encoding e ok. ["small", "medium", "large"] la [0, 1, 2] păstrează ordinea. Dar nu face asta niciodată pe categorii neordonate, minți modelul despre distanță.

Interacțiuni

Uneori două feature-uri înseamnă ceva doar împreună. Income singur nu îți spune mare lucru; income per membru de gospodărie da. Ora-zilei singură îți spune ceva; ora-zilei combinată cu ziua-săptămânii îți spune mult mai mult.

Pentru modelele tree, interacțiunile vin gratis, un tree care se împarte pe income, apoi pe household_size într-o ramură a descoperit o interacțiune. Pentru modelele liniare, trebuie să le scrii explicit:

import pandas as pd

df["income_per_member"] = df["income"] / df["household_size"]
df["weekend_x_hour"] = df["is_weekend"] * df["hour"]

Sau folosește PolynomialFeatures ca să generezi automat toate produsele perechi, util pentru seturi mici de feature-uri, periculos pentru cele mari fiindcă numărul de interacțiuni explodează cuadratic.

Feature-uri de timp

Timestamp-urile trebuie despachetate. O coloană datetime brută poartă aproape niciun semnal pe care modelul să-l poată folosi direct; extragerea componentelor da:

df["hour"] = df["timestamp"].dt.hour
df["day_of_week"] = df["timestamp"].dt.dayofweek
df["month"] = df["timestamp"].dt.month
df["is_weekend"] = df["day_of_week"].isin([5, 6]).astype(int)
df["is_business_hour"] = df["hour"].between(9, 17).astype(int)

Pentru feature-uri ciclice, oră, zi a săptămânii, lună, există o problemă subtilă: dacă codezi ora ca 0-23, modelul vede 23 și 0 la 23 de unități distanță, când în realitate sunt la 1 oră distanță. Codarea ciclică prin sin/cos rezolvă:

import numpy as np

df["hour_sin"] = np.sin(2 * np.pi * df["hour"] / 24)
df["hour_cos"] = np.cos(2 * np.pi * df["hour"] / 24)

Acum modelul vede corect trecerea peste 0. Același truc merge pentru ziua săptămânii (perioadă 7), lună (perioadă 12), ziua anului (perioadă 365,25).

Nu include timestamp-ul brut decât dacă încerci să captezi un trend pe termen lung, și chiar și atunci, folosește (timestamp - reference_date).days ca valoarea să fie mărginită.

Agregări

Aici se ascunde elevația reală pe probleme la nivel de utilizator sau de tranzacție. Pentru fiecare utilizator, calculezi feature-uri precum:

  • Suma totală cheltuită în ultimele 7 zile
  • Numărul de tranzacții în ultimele 30 de zile
  • Suma medie a tranzacțiilor în ultimele 90 de zile
  • Zile de la ultima tranzacție

În pandas, asta e un groupby plus o agregare cu fereastră. Capcana, și acum alunecăm în secțiunea de leakage, e că „ultimele 7 zile” trebuie să însemne ultimele 7 zile relativ la momentul predicției. Dacă predicția ta e pentru tranzacția T, atunci agregările trebuie să folosească doar date cu timestamp strict înaintea lui T. Includerea lui T însuși în agregare e o scurgere: modelul ajunge să vadă răspunsul.

# Gresit (scurge): agregare peste toate datele
df["user_total_spend"] = df.groupby("user_id")["amount"].transform("sum")

# Corect: rolling window strict inainte de fiecare rand
df = df.sort_values(["user_id", "timestamp"])
df["spend_past_7d"] = (
    df.groupby("user_id")
      .rolling("7D", on="timestamp", closed="left")["amount"]
      .sum()
      .reset_index(level=0, drop=True)
)

closed="left" exclude rândul curent. Fără el, ai scurs.

Feature-uri de text

Coloanele de text liber, descrieri, search queries, ticket-uri de support, nu intră brute. Cele două căi clasice:

Bag of words / TF-IDF: numără termenii, ponderează cu inverse document frequency. Ieftin, interpretabil, surprinzător de competitiv pe documente scurte.

from sklearn.feature_extraction.text import TfidfVectorizer

vec = TfidfVectorizer(max_features=5000, ngram_range=(1, 2))
X_text = vec.fit_transform(df["description"])

Embeddings: trece textul printr-un model de sentence transformer și folosește vectorul rezultat ca feature. Semnal mult mai bun, mai ales când contează mai mult sensul decât cuvintele exacte. În 2026, standardul e sentence-transformers de la Hugging Face; modelul mic all-MiniLM-L6-v2 e rapid pe CPU și suficient de bun pentru majoritatea problemelor tabular-plus-text.

from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")
embeddings = model.encode(df["description"].tolist())  # array (n, 384)

Concatenează coloanele de embedding la celelalte feature-uri și tratează-le ca pe orice alt input numeric.

Valori lipsă

Datele reale au goluri. Modelele în general nu le tratează nativ (XGBoost și LightGBM sunt excepții, ele se împart direct pe missingness). Trei strategii:

  1. Impute: înlocuiește cele lipsă cu media, mediana sau moda. Mediana e de obicei default-ul mai sigur pentru feature-uri numerice.
  2. Sentinel value: înlocuiește cu o valoare pe care modelul nu o poate confunda cu date reale. -999 pentru o feature cu valori pozitive, "_missing_" pentru o categorie.
  3. Coloană indicator: adaugă o coloană separată 0/1 care spune dacă valoarea era lipsă. Adesea combinată cu imputarea, fiindcă faptul în sine al lipsei poate fi predictiv.
from sklearn.impute import SimpleImputer

df["age_missing"] = df["age"].isna().astype(int)  # indicatorul
imputer = SimpleImputer(strategy="median")
df[["age"]] = imputer.fit_transform(df[["age"]])

Pentru modele tree-based cu suport nativ pentru valori lipsă (XGBoost, LightGBM), pur și simplu lasă NaN-urile, biblioteca le va trata și adesea face mai bine decât ar face imputarea ta.

Leakage: bug-ul care te minte

Acum partea care îți ia mai mult să o înveți decât oricare alta. Leakage se întâmplă când informații care nu ar fi disponibile la momentul predicției se strecoară în feature-urile tale de training. Modelul le prinde, scoreează grozav în cross-validation și se duce de râpă în producție fiindcă semnalul scurs nu există la inferență.

Două gusturi principale:

Target leakage: o feature include informații despre target. Exemplul clasic: predicția dacă un client va pleca, cu o feature precum cancellation_processed_date. Acel câmp se populează doar după ce plecarea s-a întâmplat. Includerea lui dă modelului un predictor aproape perfect care nu există atunci când chiar vrei să prezici.

Soluția: gândește-te bine când devine fiecare feature disponibilă. Dacă prezici la momentul T, fiecare feature trebuie să poată fi derivată din date dinaintea lui T. Dacă o coloană s-ar putea popula după evenimentul target, aruncă-o sau recalculeaz-o ca de la T.

Train-test leakage: preprocessing-ul folosește statistici din test set. Dacă faci fit la un StandardScaler pe tot setul de date înainte de split, scaler-ul a văzut media datelor de test. Scorul de cross-validation se umflă cu o cantitate mică care, pe probleme strânse, e diferența dintre lansăm și nu lansăm.

Soluția e exact pentru ce au fost gândite pipeline-urile scikit-learn: înfășoară preprocessing-ul într-un Pipeline, pasează-l către cross_val_score și biblioteca refittează totul în interiorul fiecărui fold automat.

from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.linear_model import LogisticRegression
from sklearn.model_selection import cross_val_score

pipe = Pipeline([
    ("scale", StandardScaler()),
    ("clf", LogisticRegression(max_iter=1000)),
])

# In interiorul fiecarui fold, scaler-ul refitteaza doar pe portiunea de training a foldului.
scores = cross_val_score(pipe, X, y, cv=5)

Dacă faci agregări sau target encodings în afara pipeline-ului, te-ai întors la prevenția manuală a leakage-ului: split mai întâi, apoi calculează feature-uri folosind doar datele de training, apoi transformă datele de test cu transformatorul fittat pe training.

O verificare utilă din burtă: dacă modelul tău scoreează suspect de bine din prima încercare, să zicem 99% accuracy pe o problemă pe care expertul tău de domeniu zice că e grea, presupune leakage până se demonstrează contrariul. Modelele reale pe probleme reale sunt imperfecte. Un model perfect înseamnă de obicei un model care trage cu ochiul.

O notă despre asistența AI

Când ceri unui asistent AI să scrie cod de feature engineering pentru un set de date, e bun la pattern-urile standard, dă-i un eșantion din datele tale și va sugera scaler-i, encoder-i și împărțiri de feature-uri de timp rezonabile. Pe ce nu e de încredere e la semantica de timp a agregărilor. Va sugera bucuros feature-uri precum „media tuturor tranzacțiilor anterioare” fără o fereastră sau un closed="left", iar codul rezultat va scurge viitorul în prezent.

Citește mereu codul de feature sugerat de AI cu o întrebare în minte: „în momentul în care facem o predicție, fiecare coloană de input conține doar informații care erau disponibile înainte de acel moment?” Dacă răspunsul e nu pentru orice coloană, repară-l înainte să te încrezi în metrici.

Închidere

Feature engineering e partea înceată, neglamoroasă a ML-ului tabular și totodată partea în care munca dă roade. Transformările din această lecție acoperă majoritatea cazurilor pe care le vei întâlni pe seturi reale de date; disciplina împotriva leakage-ului e ce-ți păstrează numerele cinstite. Lecția următoare, trecem tot prin modele tree-based, familia care, în 2026, încă câștigă pe probleme tabulare.

Caută