특성 공학과 선택(Feature Engineering & Selection)
좋은 특성 하나는 데이터 포인트(data point) 천 개의 가치가 있습니다.
유형: Build
언어: Python
선수 지식: Phase 1 (머신러닝(Machine Learning)을 위한 통계, 선형대수), Phase 2 Lessons 1-7
예상 시간: 약 90분
학습 목표
- 표준화(Standardization), 최소-최대 스케일링(min-max scaling), 로그 변환(Log transform), 구간화(binning) 같은 수치형 변환을 구현하고 각 변환이 언제 적절한지 설명합니다.
- 범주형 특성(Categorical feature)을 위한 원-핫 인코딩(one-hot encoding), 라벨 인코딩(label encoding), 타깃 인코딩(target encoding)을 만들고 타깃 인코딩의 데이터 누수(Data leakage) 위험을 식별합니다.
- TF-IDF 벡터화기(vectorizer)를 처음부터 만들고, 텍스트 분류에서 원시 단어 빈도(raw word count)보다 더 나은 이유를 설명합니다.
- 분산 기준(Variance threshold), 상관관계(Correlation), 상호정보량(Mutual information) 같은 필터(filter) 기반 특성 선택(Feature selection)을 적용해 차원을 줄입니다.
문제
데이터셋이 있습니다. 알고리즘을 골라 학습했지만 결과가 평범합니다. 더 복잡한 알고리즘을 써도 여전히 평범합니다. 일주일 동안 하이퍼파라미터를 튜닝해도 개선은 작습니다.
그런데 누군가 원시 데이터(raw data)를 더 나은 특성으로 바꾸자, 단순한 로지스틱 회귀(Logistic regression)가 정성껏 튜닝한 그래디언트 부스팅 앙상블(gradient-boosted ensemble)보다 더 좋은 결과를 냅니다.
이 일은 자주 일어납니다. 전통적인 머신러닝(Classical ML)에서는 데이터의 표현 방식이 알고리즘 선택보다 더 중요할 때가 많습니다. 집값 모델에서 square footage(전용 면적)와 number of bedrooms(침실 수)를 쓰는 모델은 address(주소)를 원시 문자열(raw string)로 넣은 모델보다 거의 항상 더 낫습니다. 아무리 정교한 학습기(learner)라도, 모델은 주어진 표현 안에서만 학습할 수 있습니다.
특성 공학(Feature engineering)은 원시 데이터를 모델이 패턴(pattern)을 더 쉽게 찾을 수 있는 표현으로 바꾸는 과정입니다. 특성 선택(Feature selection)은 신호(signal)를 더하지 않고 잡음(noise)만 더하는 특성을 버리는 과정입니다. 둘은 전통적 머신러닝에서 가장 영향력(leverage)이 큰 활동입니다.
개념
특성 파이프라인(Feature Pipeline)
flowchart LR
A[원시 데이터] --> B[결측값 처리]
B --> C[수치형 변환]
B --> D[범주형 인코딩]
B --> E[텍스트 특성]
C --> F[특성 상호작용]
D --> F
E --> F
F --> G[특성 선택]
G --> H[모델 입력 데이터]
수치형 특성
원시 수치(raw number)는 그대로 모델에 넣기 어려운 경우가 많습니다. 자주 쓰는 변환은 다음과 같습니다.
스케일링(Scaling): 특성을 같은 범위에 둡니다. K-Means, KNN, SVM 같은 거리 기반 알고리즘이 모든 특성을 공정하게 보도록 하기 위해 필요합니다. 최소-최대 스케일링은 값을 [0, 1]로 매핑합니다. 표준화, 즉 z-점수(z-score)는 평균 0, 표준편차 1로 매핑합니다.
로그 변환(Log transform): 오른쪽으로 긴 분포, 예를 들어 소득, 인구, 단어 빈도(word count)를 압축합니다. 곱셈 관계를 덧셈 관계처럼 다루기 쉽게 만들 수 있습니다.
구간화(Binning): 연속값을 범주(category)로 바꿉니다. 특성과 타깃(target)의 관계가 비선형이지만 계단식일 때 유용합니다. 예를 들어 나이대를 만들 수 있습니다.
다항 특성(Polynomial features): x^2, x^3, x1*x2 같은 항을 만듭니다. 특성 수가 늘어나는 대가로 선형 모델이 비선형 관계를 포착하게 합니다.
범주형 특성
모델은 숫자를 필요로 합니다. 범주는 인코딩(encoding)이 필요합니다.
원-핫 인코딩(One-hot encoding): 각 범주마다 이진 열(column)을 만듭니다. color = red/blue/green은 is_red, is_blue, is_green 세 열이 됩니다. 카디널리티(cardinality)가 낮은 특성에는 잘 동작하지만 범주가 많으면 열이 폭발합니다.
라벨 인코딩(Label encoding): 각 범주를 정수로 매핑합니다. 예를 들어 red=0, blue=1, green=2입니다. 하지만 거짓 순서를 만들 수 있습니다. 모델이 green > blue > red라고 오해할 수 있습니다. 개별 값을 기준으로 분할(split)하는 트리(tree) 기반 모델에만 주로 적절합니다.
타깃 인코딩(Target encoding): 각 범주를 해당 범주의 타깃 평균으로 바꿉니다. 강력하지만 위험합니다. 데이터 누수 위험이 큽니다. 반드시 학습 데이터에서만 계산하고 테스트 데이터에는 학습 데이터에서 얻은 인코딩을 적용해야 합니다.
텍스트 특성
카운트 벡터라이저(Count vectorizer): 문서에 각 단어가 몇 번 등장했는지 셉니다. "the cat sat on the mat"은 {the: 2, cat: 1, sat: 1, on: 1, mat: 1}처럼 표현됩니다.
TF-IDF: 단어 빈도-역문서 빈도(Term Frequency-Inverse Document Frequency)입니다. 전체 문서에서 얼마나 구별력 있는 단어인지에 따라 단어에 가중치를 줍니다. "the"처럼 흔한 단어는 낮은 가중치를 받고, 드물고 특징적인 단어는 높은 가중치를 받습니다.
TF(word, doc) = count(word in doc) / total words in doc
IDF(word) = log(total docs / docs containing word)
TF-IDF = TF * IDF
결측값(Missing Values)
현실 데이터에는 빈칸이 있습니다. 전략은 다음과 같습니다.
- 행 삭제(Drop rows): 결측 데이터가 드물고 무작위일 때만 사용합니다.
- 평균/중앙값 대체(Mean/median imputation): 단순하며 분포 형태를 어느 정도 보존합니다. 중앙값은 이상치에 더 강합니다.
- 최빈값 대체(Mode imputation): 범주형 특성에 사용합니다.
- 지시 열(Indicator column): 대체 전에
was_this_missing 같은 이진 열을 추가합니다. 값이 결측이라는 사실 자체가 신호일 수 있습니다.
- 앞/뒤 채우기(Forward/backward fill): 시계열 데이터에서 사용합니다.
특성 상호작용
때로는 관계가 조합 안에 있습니다. height와 weight 각각보다 BMI = weight / height^2가 더 예측적일 수 있습니다. 특성 상호작용은 특성 공간을 빠르게 키우므로 도메인 지식(domain knowledge)으로 필요한 조합을 골라야 합니다.
특성 선택
특성이 많다고 항상 좋은 것은 아닙니다. 관련 없는 특성은 잡음을 더하고 학습 시간을 늘리며 과적합을 유발할 수 있습니다.
필터 방식(Filter methods, 모델 적용 이전):
- 상관관계(Correlation): 서로 매우 상관된 중복 특성(redundant feature)을 제거합니다.
- 상호정보량(Mutual information): 어떤 특성을 알면 타깃에 대한 불확실성이 얼마나 줄어드는지 측정합니다.
- 분산 기준(Variance threshold): 거의 변하지 않는 특성을 제거합니다.
래퍼 방식(Wrapper methods, 모델 기반):
- L1 정규화(L1 regularization; Lasso): 관련 없는 특성의 가중치(weight)를 정확히 0으로 밀어냅니다.
- 재귀적 특성 제거(Recursive feature elimination): 학습하고, 가장 덜 중요한 특성을 제거하고, 반복합니다.
왜 중요한가: 좋은 특성 10개로 만든 모델은 좋은 특성 10개와 잡음 특성 90개로 만든 모델보다 보통 더 좋습니다. 잡음 특성은 학습 데이터에만 있는 패턴에 과적합할 기회를 줍니다.
만들어 보기
Step 1: 수치형 변환 처음부터 구현하기
import math
def min_max_scale(values):
min_val = min(values)
max_val = max(values)
if max_val == min_val:
return [0.0] * len(values)
return [(v - min_val) / (max_val - min_val) for v in values]
def standardize(values):
n = len(values)
mean = sum(values) / n
variance = sum((v - mean) ** 2 for v in values) / n
std = math.sqrt(variance) if variance > 0 else 1.0
return [(v - mean) / std for v in values]
def log_transform(values):
return [math.log(v + 1) for v in values]
def bin_values(values, n_bins=5):
min_val = min(values)
max_val = max(values)
bin_width = (max_val - min_val) / n_bins
if bin_width == 0:
return [0] * len(values)
result = []
for v in values:
bin_idx = int((v - min_val) / bin_width)
bin_idx = min(bin_idx, n_bins - 1)
result.append(bin_idx)
return result
def polynomial_features(row, degree=2):
n = len(row)
result = list(row)
if degree >= 2:
for i in range(n):
result.append(row[i] ** 2)
for i in range(n):
for j in range(i + 1, n):
result.append(row[i] * row[j])
return result
Step 2: 범주형 인코딩 처음부터 구현하기
def one_hot_encode(values):
categories = sorted(set(values))
cat_to_idx = {cat: i for i, cat in enumerate(categories)}
n_cats = len(categories)
encoded = []
for v in values:
row = [0] * n_cats
row[cat_to_idx[v]] = 1
encoded.append(row)
return encoded, categories
def label_encode(values):
categories = sorted(set(values))
cat_to_int = {cat: i for i, cat in enumerate(categories)}
return [cat_to_int[v] for v in values], cat_to_int
def target_encode(feature_values, target_values, smoothing=10):
global_mean = sum(target_values) / len(target_values)
category_stats = {}
for feat, target in zip(feature_values, target_values):
if feat not in category_stats:
category_stats[feat] = {"sum": 0.0, "count": 0}
category_stats[feat]["sum"] += target
category_stats[feat]["count"] += 1
encoding = {}
for cat, stats in category_stats.items():
cat_mean = stats["sum"] / stats["count"]
weight = stats["count"] / (stats["count"] + smoothing)
encoding[cat] = weight * cat_mean + (1 - weight) * global_mean
return [encoding[v] for v in feature_values], encoding
Step 3: 텍스트 특성 처음부터 구현하기
def count_vectorize(documents):
vocab = {}
idx = 0
for doc in documents:
for word in doc.lower().split():
if word not in vocab:
vocab[word] = idx
idx += 1
vectors = []
for doc in documents:
vec = [0] * len(vocab)
for word in doc.lower().split():
vec[vocab[word]] += 1
vectors.append(vec)
return vectors, vocab
def tfidf(documents):
n_docs = len(documents)
vocab = {}
idx = 0
for doc in documents:
for word in doc.lower().split():
if word not in vocab:
vocab[word] = idx
idx += 1
doc_freq = {}
for doc in documents:
seen = set()
for word in doc.lower().split():
if word not in seen:
doc_freq[word] = doc_freq.get(word, 0) + 1
seen.add(word)
vectors = []
for doc in documents:
words = doc.lower().split()
word_count = len(words)
tf_map = {}
for word in words:
tf_map[word] = tf_map.get(word, 0) + 1
vec = [0.0] * len(vocab)
for word, count in tf_map.items():
tf = count / word_count
idf = math.log(n_docs / doc_freq[word])
vec[vocab[word]] = tf * idf
vectors.append(vec)
return vectors, vocab
Step 4: 결측값 대치 처음부터 구현하기
def impute_mean(values):
present = [v for v in values if v is not None]
if not present:
return [0.0] * len(values), 0.0
mean = sum(present) / len(present)
return [v if v is not None else mean for v in values], mean
def impute_median(values):
present = sorted(v for v in values if v is not None)
if not present:
return [0.0] * len(values), 0.0
n = len(present)
if n % 2 == 0:
median = (present[n // 2 - 1] + present[n // 2]) / 2
else:
median = present[n // 2]
return [v if v is not None else median for v in values], median
def impute_mode(values):
present = [v for v in values if v is not None]
if not present:
return values, None
counts = {}
for v in present:
counts[v] = counts.get(v, 0) + 1
mode = max(counts, key=counts.get)
return [v if v is not None else mode for v in values], mode
def add_missing_indicator(values):
return [0 if v is not None else 1 for v in values]
Step 5: 특성 선택 처음부터 구현하기
def correlation(x, y):
n = len(x)
mean_x = sum(x) / n
mean_y = sum(y) / n
cov = sum((xi - mean_x) * (yi - mean_y) for xi, yi in zip(x, y)) / n
std_x = math.sqrt(sum((xi - mean_x) ** 2 for xi in x) / n)
std_y = math.sqrt(sum((yi - mean_y) ** 2 for yi in y) / n)
if std_x == 0 or std_y == 0:
return 0.0
return cov / (std_x * std_y)
def mutual_information(feature, target, n_bins=10):
feat_min = min(feature)
feat_max = max(feature)
bin_width = (feat_max - feat_min) / n_bins if feat_max != feat_min else 1.0
feat_binned = [
min(int((f - feat_min) / bin_width), n_bins - 1) for f in feature
]
n = len(feature)
target_classes = sorted(set(target))
feat_bins = sorted(set(feat_binned))
p_feat = {}
for b in feat_bins:
p_feat[b] = feat_binned.count(b) / n
p_target = {}
for t in target_classes:
p_target[t] = target.count(t) / n
mi = 0.0
for b in feat_bins:
for t in target_classes:
joint_count = sum(
1 for fb, tv in zip(feat_binned, target) if fb == b and tv == t
)
p_joint = joint_count / n
if p_joint > 0:
mi += p_joint * math.log(p_joint / (p_feat[b] * p_target[t]))
return mi
def variance_threshold(features, threshold=0.01):
n_features = len(features[0])
n_samples = len(features)
selected = []
for j in range(n_features):
col = [features[i][j] for i in range(n_samples)]
mean = sum(col) / n_samples
var = sum((v - mean) ** 2 for v in col) / n_samples
if var >= threshold:
selected.append(j)
return selected
def remove_correlated(features, threshold=0.9):
n_features = len(features[0])
n_samples = len(features)
to_remove = set()
for i in range(n_features):
if i in to_remove:
continue
col_i = [features[r][i] for r in range(n_samples)]
for j in range(i + 1, n_features):
if j in to_remove:
continue
col_j = [features[r][j] for r in range(n_samples)]
corr = abs(correlation(col_i, col_j))
if corr >= threshold:
to_remove.add(j)
return [i for i in range(n_features) if i not in to_remove]
Step 6: 전체 파이프라인과 데모
import random
def make_housing_data(n=200, seed=42):
random.seed(seed)
data = []
for _ in range(n):
sqft = random.uniform(500, 5000)
bedrooms = random.choice([1, 2, 3, 4, 5])
age = random.uniform(0, 50)
neighborhood = random.choice(["downtown", "suburbs", "rural"])
has_pool = random.choice([True, False])
sqft_with_missing = sqft if random.random() > 0.05 else None
age_with_missing = age if random.random() > 0.08 else None
price = (
50 * sqft
+ 20000 * bedrooms
- 1000 * age
+ (50000 if neighborhood == "downtown" else 10000 if neighborhood == "suburbs" else 0)
+ (15000 if has_pool else 0)
+ random.gauss(0, 20000)
)
data.append({
"sqft": sqft_with_missing,
"bedrooms": bedrooms,
"age": age_with_missing,
"neighborhood": neighborhood,
"has_pool": has_pool,
"price": price,
})
return data
if __name__ == "__main__":
data = make_housing_data(200)
print("=== 원시 데이터 예시 ===")
for row in data[:3]:
print(f" {row}")
sqft_raw = [d["sqft"] for d in data]
age_raw = [d["age"] for d in data]
prices = [d["price"] for d in data]
print("\n=== 결측값 처리 ===")
sqft_missing = sum(1 for v in sqft_raw if v is None)
age_missing = sum(1 for v in age_raw if v is None)
print(f" sqft 결측: {sqft_missing}/{len(sqft_raw)}")
print(f" age 결측: {age_missing}/{len(age_raw)}")
sqft_indicator = add_missing_indicator(sqft_raw)
age_indicator = add_missing_indicator(age_raw)
sqft_imputed, sqft_fill = impute_median(sqft_raw)
age_imputed, age_fill = impute_mean(age_raw)
print(f" sqft는 중앙값으로 채웠습니다: {sqft_fill:.0f}")
print(f" age는 평균으로 채웠습니다: {age_fill:.1f}")
print("\n=== 수치형 변환 ===")
sqft_scaled = standardize(sqft_imputed)
age_scaled = min_max_scale(age_imputed)
sqft_log = log_transform(sqft_imputed)
age_binned = bin_values(age_imputed, n_bins=5)
print(f" sqft 표준화: mean={sum(sqft_scaled)/len(sqft_scaled):.4f}, std={math.sqrt(sum(v**2 for v in sqft_scaled)/len(sqft_scaled)):.4f}")
print(f" age min-max: [{min(age_scaled):.2f}, {max(age_scaled):.2f}]")
print(f" age 구간: {sorted(set(age_binned))}")
print("\n=== 범주형 인코딩(Categorical Encoding) ===")
neighborhoods = [d["neighborhood"] for d in data]
ohe, ohe_cats = one_hot_encode(neighborhoods)
print(f" 원-핫 범주(One-hot categories): {ohe_cats}")
print(f" 인코딩 예시: {neighborhoods[0]} -> {ohe[0]}")
le, le_map = label_encode(neighborhoods)
print(f" 라벨 인코딩 맵(Label encoding map): {le_map}")
te, te_map = target_encode(neighborhoods, prices, smoothing=10)
print(f" 타깃 인코딩(Target encoding): {({k: round(v) for k, v in te_map.items()})}")
print("\n=== 텍스트 특성 ===")
descriptions = [
"large modern house with pool",
"small cozy cottage near downtown",
"spacious family home with large yard",
"modern apartment downtown with view",
"rustic cabin in rural area",
]
cv, cv_vocab = count_vectorize(descriptions)
print(f" 어휘 크기(Vocabulary size): {len(cv_vocab)}")
print(f" 문서 0의 0이 아닌 특성 수: {sum(1 for v in cv[0] if v > 0)}")
tf, tf_vocab = tfidf(descriptions)
print(f" TF-IDF 어휘 크기: {len(tf_vocab)}")
top_words = sorted(tf_vocab.keys(), key=lambda w: tf[0][tf_vocab[w]], reverse=True)[:3]
print(f" 문서 0의 상위 TF-IDF 단어: {top_words}")
print("\n=== 다항식 특성 ===")
sample_row = [sqft_scaled[0], age_scaled[0]]
poly = polynomial_features(sample_row, degree=2)
print(f" 입력: {[round(v, 4) for v in sample_row]}")
print(f" 다항식 특성: {[round(v, 4) for v in poly]}")
print(" 특성: [x1, x2, x1^2, x2^2, x1*x2]")
print("\n=== 특성 선택 ===")
feature_matrix = [
[sqft_scaled[i], age_scaled[i], float(sqft_indicator[i]), float(age_indicator[i])]
+ ohe[i]
for i in range(len(data))
]
print(f" 전체 특성 수: {len(feature_matrix[0])}")
surviving_var = variance_threshold(feature_matrix, threshold=0.01)
print(f" 분산 임계값(0.01) 이후: 특성 {len(surviving_var)}개 유지")
surviving_corr = remove_correlated(feature_matrix, threshold=0.9)
print(f" 상관관계 필터(0.9) 이후: 특성 {len(surviving_corr)}개 유지")
binary_prices = [1 if p > sum(prices) / len(prices) else 0 for p in prices]
print("\n 타깃과의 상호 정보량(Mutual information):")
feature_names = ["sqft", "age", "sqft_missing", "age_missing"] + [f"neigh_{c}" for c in ohe_cats]
for j in range(len(feature_matrix[0])):
col = [feature_matrix[i][j] for i in range(len(feature_matrix))]
mi = mutual_information(col, binary_prices, n_bins=10)
print(f" {feature_names[j]}: MI={mi:.4f}")
print("\n 가격과의 상관관계(Correlation):")
for j in range(len(feature_matrix[0])):
col = [feature_matrix[i][j] for i in range(len(feature_matrix))]
corr = correlation(col, prices)
print(f" {feature_names[j]}: r={corr:.4f}")
사용하기
scikit-learn에서는 이런 변환을 조합 가능한 파이프라인(composable pipeline)으로 묶습니다.
from sklearn.preprocessing import StandardScaler, OneHotEncoder, PolynomialFeatures
from sklearn.impute import SimpleImputer
from sklearn.feature_extraction.text import TfidfVectorizer
from sklearn.feature_selection import mutual_info_classif, VarianceThreshold
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
numeric_pipe = Pipeline([
("imputer", SimpleImputer(strategy="median")),
("scaler", StandardScaler()),
])
categorical_pipe = Pipeline([
("encoder", OneHotEncoder(sparse_output=False)),
])
preprocessor = ColumnTransformer([
("num", numeric_pipe, ["sqft", "age"]),
("cat", categorical_pipe, ["neighborhood"]),
])
직접 구현한 버전은 각 변환(transform) 내부에서 어떤 계산이 일어나는지 보여줍니다. 라이브러리 버전은 경계 사례(edge case) 처리, 희소 행렬(sparse matrix) 지원, 파이프라인 조합(pipeline composition)을 더하지만 수학은 같습니다.
출하하기
이 lesson의 산출물은 다음입니다.
outputs/prompt-feature-engineer.md — 원시 데이터에서 체계적으로 특성(feature)을 설계하기 위한 프롬프트(prompt)
연습문제
-
(쉬움) 강건 스케일링(Robust scaling)을 추가합니다. 평균과 표준편차 대신 중앙값과 사분위수 범위(IQR; Interquartile range)를 사용합니다. 극단적 이상치가 있는 데이터에서 표준 스케일링(standard scaling)과 비교합니다.
-
(중간) 단일 제외 타깃 인코딩(Leave-one-out target encoding)을 구현합니다. 각 행(row)에 대해 자기 타깃 값을 제외하고 범주별 타깃 평균을 계산합니다. 단순 타깃 인코딩(naive target encoding)보다 과적합이 줄어드는지 보입니다.
-
(어려움) 분산 기준, 상관관계 필터링, 상호정보량 순위를 결합한 자동 특성 선택 파이프라인을 만듭니다. 주택 데이터셋(housing dataset)에 적용하고, 단순 선형 회귀로 전체 특성과 선택된 특성의 성능을 비교합니다.
핵심 용어
| 용어 | 흔한 설명 | 실제 의미 |
|---|
| 특성 공학(Feature engineering) | 새 열 만들기 | 모델이 패턴을 찾기 쉽도록 원시 데이터를 표현으로 변환하는 작업 |
| 표준화(Standardization) | 정규화하기 | 평균을 빼고 표준편차로 나누어 특성 평균을 0, 표준편차를 1로 만드는 작업 |
| 원-핫 인코딩(One-hot encoding) | 더미 변수(dummy variable) 만들기 | 범주마다 이진 열을 만들고 각 행에서 정확히 하나만 1이 되게 하는 방식 |
| 타깃 인코딩(Target encoding) | 정답을 써서 인코딩 | 범주를 해당 범주의 평균 타깃 값으로 바꾸는 방식. 평활화(smoothing)가 필요하며 데이터 누수 위험이 큼 |
| TF-IDF | 고급 단어 빈도(word count) | 단어 빈도(Term Frequency)와 역문서 빈도(Inverse Document Frequency)를 곱해 말뭉치(corpus)에서 구별력 있는 단어에 더 큰 가중치를 주는 표현 |
| 대치(Imputation) | 빈칸 채우기 | 결측값(missing value)을 평균, 중앙값, 최빈값 또는 모델 예측값으로 대체하는 작업 |
| 특성 선택(Feature selection) | 나쁜 열 버리기 | 신호보다 잡음이나 중복(redundancy)을 더하는 특성을 제거하는 작업 |
| 상호정보량(Mutual information) | 한 값이 다른 값을 얼마나 알려주는지 | X를 관찰했을 때 Y의 불확실성이 얼마나 줄어드는지 측정하는 값 |
| 데이터 누수(Data leakage) | 실수로 부정행위(cheating)하기 | 예측 시점에는 사용할 수 없는 정보를 학습 중 특성에 넣어 성능이 과대평가되는 문제 |
더 읽을거리