scikit-learn에는 아이디어가 하나뿐이고 그것이 어디서나 반복됩니다. 그 하나를 잡고 나면 수백 개의 추정량이 서로 갈아 끼울 수 있는 물건이 됩니다. 새 모형을 만날 때마다 문서를 처음부터 읽을 일이 없어집니다.
Scikit-learn has one idea, repeated everywhere. Once you have it, several hundred estimators become interchangeable and you stop reading documentation for each new one.
이 편은 회귀 문제 하나로 그 아이디어를 끝까지 따라갑니다. 데이터를 불러오고, 들여다보고, 나누고, 모형을 적합하고, 정직하게 평가합니다. 쓰는 모형이 랜덤 포레스트일 뿐, 아래 코드는 거의 전부 랜덤 포레스트와 무관합니다. 생성자만 바꾸면 나머지는 그대로입니다. 그게 요점입니다.
This session walks that idea end to end on a regression problem: load, inspect, split, fit, evaluate. The model happens to be a random forest, but almost nothing below is specific to random forests — swap the constructor and the rest is unchanged. That is the point.
추정량 APIThe estimator API
모든 scikit-learn 추정량은 같은 세 메서드를 구현합니다.
Every scikit-learn estimator implements the same three methods.
fit(X, y)— 훈련 데이터에서 모수를 학습합니다. 추정량 자신을 바꾸고 자신을 반환합니다.predict(X)— 새 데이터에 대한 예측을 냅니다.score(X, y)— 기본 적합도 숫자 하나를 줍니다.
fit(X, y)learns parameters from training data. It mutates the estimator and returns it.predict(X)produces predictions for new data.score(X, y)returns a default goodness-of-fit number.
변환기는 여기에 transform(X)를, 둘을 함께 할 때는 fit_transform(X)을 더합니다. 사실상 이게 인터페이스 전부입니다. 선형회귀와 그래디언트 부스팅 앙상블과 신경망이 모두 정확히 이 메서드들을 내놓기 때문에 파이프라인에서 모형을 한 줄로 갈아 끼울 수 있습니다.
Transformers add transform(X), and fit_transform(X) when both happen together. That is essentially the whole interface. A linear regression, a gradient-boosted ensemble and a neural network all present exactly these methods, which is what makes it possible to swap models in a pipeline by changing one line.
딸려 오는 하나의 엄격한 규칙The one strict rule that comes with it
이것이 응용 ML 코드에서 가장 흔한 방법론적 버그이고 눈에 보이지 않습니다. 코드는 멀쩡히 돌고 숫자만 있어야 할 것보다 좋게 나옵니다.
This is the single most common methodological bug in applied ML code, and it is invisible: the code runs fine and the number just looks better than it should.
해법은 전처리를 Pipeline 안에 넣는 것입니다. 그러면 교차검증이 폴드마다 모든 단계를 다시 적합합니다.
The fix is to put preprocessing inside a Pipeline, so cross-validation refits every step on each fold.
from sklearn.pipeline import make_pipeline
from sklearn.preprocessing import StandardScaler
from sklearn.impute import SimpleImputer
from sklearn.ensemble import RandomForestRegressor
model = make_pipeline(
SimpleImputer(strategy="median"),
StandardScaler(),
RandomForestRegressor(random_state=0),
)
# every step is refitted inside each fold - no leakage
from sklearn.model_selection import cross_val_score
scores = cross_val_score(model, X, y, cv=5, scoring="r2")데이터셋을 두고 한마디A note on the dataset
이 주제의 고전 예제는 수십 년간 보스턴 주택 데이터였습니다. 지금은 scikit-learn에서 제거되었습니다(1.0에서 폐기, 1.2에서 삭제). 변수 하나가 지역별 흑인 거주 비율을 담고 있고 그것이 집값에 영향을 준다는 전제 위에 만들어졌기 때문입니다. 윤리적으로 문제 있는 변수가 30년간 벤치마크 안에 검토 없이 앉아 있을 수 있다는 것을 보여 주는 사례입니다.
The classic example for this topic was the Boston housing dataset for decades. It has since been removed from scikit-learn (deprecated in 1.0, deleted in 1.2), because one of its features encodes the proportion of Black residents by town and was constructed on the assumption that this affects house prices. A genuinely instructive example of how an ethically loaded variable can sit unexamined in a benchmark for thirty years.
캘리포니아 주택 데이터를 쓰십시오. 문제의 모양도 같고 API도 같고 짐도 없습니다.
Use the California housing data instead — same shape of problem, same API, no baggage.
import numpy as np
from sklearn.datasets import fetch_california_housing
from sklearn.model_selection import train_test_split
from sklearn import metrics
housing = fetch_california_housing()
X, y = housing.data, housing.target
X_train, X_test, y_train, y_test = train_test_split(
X, y, test_size=0.2, random_state=1
)
print("train:", X_train.shape, " test:", X_test.shape)
forest = RandomForestRegressor(random_state=0)
forest.fit(X_train, y_train)train: (16512, 8) test: (4128, 8)
두 번 평가하는 이유Why you evaluate twice
기본 설정의 RandomForestRegressor는 모든 잎이 순수해질 때까지 트리를 키웁니다. 그래서 훈련 데이터에서는 목표값을 거의 그대로 재현합니다. 테스트 점수는 훨씬 낮게 떨어집니다. 그 격차가 버그가 아니라 모형의 분산이고, 그 크기를 보는 것이 두 번 평가하는 이유 전부입니다.
A default RandomForestRegressor grows trees until every leaf is pure, so on training data it reproduces the targets almost exactly. The test score lands far lower. That gap is not a bug — it is the model variance made visible, and seeing its size is the whole reason for evaluating twice.
def report(name, y_true, y_pred):
print(f"{name:>6} R2={metrics.r2_score(y_true, y_pred):.3f}"
f" MAE={metrics.mean_absolute_error(y_true, y_pred):.3f}"
f" RMSE={np.sqrt(metrics.mean_squared_error(y_true, y_pred)):.3f}")
report("train", y_train, forest.predict(X_train))
report("test", y_test, forest.predict(X_test)) train R2=0.974 MAE=0.182 RMSE=0.186
test R2=0.805 MAE=0.327 RMSE=0.510
어느 쪽 숫자도 혼자서는 쓸모가 없습니다.
Neither number alone is useful.
- 훈련 점수만 보면 암기를 재는 것입니다. 훈련 세트를 저장한 모형은 만점을 받고 아무것도 예측하지 못합니다.
- 테스트 점수만 보면 정직하긴 하지만 20% 분할 한 번으로는 잡음이 크고 그것에 맞춰 튜닝하기 시작하는 순간 정직하지도 않게 됩니다.
- Training score alone measures memorisation. A model that stores the training set scores perfectly and predicts nothing.
- Test score alone is honest but noisy on a single 20% split, and it stops being honest the moment you start tuning against it.
두 번째가 미묘하고 더 중요합니다. 테스트 점수를 올리려고 하이퍼파라미터를 조정하는 순간 테스트 세트가 모형에 정보를 준 것이고 더 이상 미래 성능의 불편추정치가 아닙니다. 표준 대응은 삼분할 — 훈련은 적합, 검증은 튜닝, 테스트는 마지막에 딱 한 번 — 이거나 훈련 세트 안에서 하는 교차검증이고, 그게 다음 편입니다.
The second point matters more than it looks. Once you adjust hyperparameters to improve the test score, the test set has informed your model and is no longer an unbiased estimate of future performance. The standard remedy is a three-way split — train to fit, validation to tune, test touched exactly once — or cross-validation on the training set, which is the next post.
지표 셋을 함께 보는 것도 이유가 있습니다. R²는 단위가 없어서 설명된 분산 비율을 알려 주고 RMSE는 목표 변수의 단위로 전형적인 오차 크기를 알려 줍니다. MAE도 목표 단위지만 모든 오차를 동등하게 다루는 반면 RMSE는 큰 오차를 더 벌합니다. 셋 다 보고하는 건 공짜이고 서로 어긋날 때 그 어긋남이 정보입니다.
Reporting three metrics has a reason too. R² is unit-free and gives the share of variance explained; RMSE is in the units of the target and gives typical error size; MAE is also in target units but weighs all errors equally where RMSE punishes large ones. All three is cheap, and they disagree in informative ways.
실무에서In practice
random_state를 항상 지정하십시오.train_test_split도RandomForestRegressor도 확률적입니다. 시드가 없으면 실행마다 결과가 움직이고 진짜 개선과 재표본 잡음을 구분할 수 없습니다.- 시간 축이 있는 데이터를 무작위로 나누지 마십시오. 무작위 분할은 모형이 미래로 훈련해서 과거를 예측하게 만듭니다.
TimeSeriesSplit을 쓰십시오. 뒤의 GARCH 편이 여기 의존합니다. - 랜덤 포레스트에는 스케일링이 필요 없지만 파이프라인에는 남겨 두십시오. 트리는 임계값으로 분할하므로 단조 변환에 불변입니다. 선형·거리 기반 모형은 아닙니다. 스케일러를 파이프라인에 두는 건 비용이 없고 나중에 Lasso로 갈아 끼울 때 조용히 깨지지 않게 해 줍니다.
feature_importances_는 인과가 아닙니다. 그 변수로 분할했을 때 불순도가 얼마나 줄었는지를 재는 값입니다. 상관된 예측변수끼리는 중요도를 임의로 나눠 갖고 어떤 변수는 다른 것의 대리 역할만으로 높게 나옵니다. "X를 바꾸면 어떻게 되는가"가 질문이라면 이 숫자는 답하지 않습니다. 이 시리즈의 인과추론 섹션이 존재하는 이유입니다.
- Always set
random_state. Bothtrain_test_splitandRandomForestRegressorare stochastic. Without a seed, results move between runs and you cannot tell a genuine improvement from resampling noise. - Do not split on time randomly. A random split lets the model train on the future and predict the past. Use
TimeSeriesSplit. The GARCH post later depends on this. - Random forests do not need scaling, but keep the scaler anyway. Trees split on thresholds and are invariant to monotone transforms; linear and distance-based models are not. Keeping a scaler in the pipeline costs nothing and means swapping in a Lasso later does not silently break.
feature_importances_is not causal. It measures how much a split on that variable reduced impurity. Correlated predictors share importance arbitrarily, and a variable can score highly purely as a proxy for something else. If the question is "what happens if we change X", this number does not answer it — which is why the causal inference section of this series exists.
여기서 본 훈련·테스트 격차가 교차검증의 동기가 됩니다. 임의의 분할 하나를 믿는 대신 여러 분할의 성능을 평균 내는 것이 다음 편입니다. 그다음에는 API 아래로 내려가 fit이 실제로 무슨 일을 하는지 직접 구현해 봅니다.
The train/test gap seen here motivates cross-validation, which is the next post: rather than trusting one arbitrary split, average performance over several. After that we drop below the API entirely and implement what fit is actually doing.