特徴量エンジニアリング

モデルを変えなくても、入力の表現を変えるだけで予測性能は大きく動きます。特徴量エンジニアリングは、元データを機械学習モデルが扱いやすい形へ整える作業です。

数値列とカテゴリ列をどう扱うか、歪んだ分布をどう直すか、列どうしの関係をどう新しい特徴へ変えるか。その積み重ねが、モデルの見える世界を決めます。

表現が変わると学習のしやすさが変わる

同じ回帰モデルでも、駅距離の分布を対数変換したり、面積と部屋数から 1 部屋あたり面積を作ったりするだけで、関係の見え方はかなり変わります。

特徴量設計は付け足しではなく、問題設定をモデルが読める言語へ翻訳する工程です。

列の意味を確かめ、欠損を読み、分布を整え、素朴なベースラインを置き、そのうえで意味のある列を追加する。こうした順序で進めると、改善の理由を後から説明できます。

基本語彙

個々の技法を暗記するよりも、どの変換がどのモデルを助けるのかを比較しながら掴む方が実践的です。

変換の前後で何が変わったか

特徴量設計では、常に元の状態との比較が必要です。変換前後の分布、列の意味、評価指標の差が対応して見えると、改善が偶然か必然かを判断しやすくなります。

ありがちな失敗

列を増やせば性能が上がるとは限りません。意味の薄い列やテスト側の情報を混ぜれば、リークや過学習がすぐに起こります。

ColumnTransformerPipeline が重要なのは、整理のためだけでなく、その事故を防ぐためです。

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns

from sklearn.model_selection import train_test_split, cross_val_score
from sklearn.compose import ColumnTransformer
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import OneHotEncoder, StandardScaler, PolynomialFeatures, FunctionTransformer
from sklearn.metrics import mean_absolute_error, r2_score
from sklearn.linear_model import LinearRegression, Ridge
from sklearn.ensemble import RandomForestRegressor

sns.set_theme(style="whitegrid", context="notebook")

1. 元データの癖を読む

特徴量設計は列の意味と型を把握するところから始まります。数値列とカテゴリ列の区別、欠損の位置、外れ値の存在が見えていないままでは、どんな変換も空回りしやすくなります。

rng = np.random.default_rng(42)
n = 600

area = rng.normal(65, 18, n).clip(20, 150)
rooms = rng.integers(1, 6, n)
age = rng.integers(0, 40, n)
distance = rng.normal(18, 8, n).clip(1, 45)
station = rng.choice(["A", "B", "C", "D"], size=n, p=[0.30, 0.30, 0.25, 0.15])
floor = rng.integers(1, 16, n)

station_effect = pd.Series(station).map({"A": 180, "B": 120, "C": 60, "D": 20}).to_numpy()
noise = rng.normal(0, 35, n)
price = 45 + 4.2 * area + 16 * rooms - 2.3 * age - 3.8 * distance + station_effect + 2.8 * floor + noise

df = pd.DataFrame({
    "area": area,
    "rooms": rooms,
    "age": age,
    "distance_to_station": distance,
    "station": station,
    "floor": floor,
    "price": price,
})

# 欠損を意図的に作る
missing_idx_num = rng.choice(df.index, size=35, replace=False)
missing_idx_cat = rng.choice(df.index, size=20, replace=False)
df.loc[missing_idx_num, "distance_to_station"] = np.nan
df.loc[missing_idx_cat, "station"] = np.nan

df.head()

infoisna は地味ですが重要です。派手な新特徴より先に、データの現状を正確に読む力の方がモデル品質を左右します。

print(df.info())
print("\n欠損数:\n", df.isna().sum())

2. 分布をそのまま使うか、変換するか

歪んだ分布は、そのままだと線形モデルにとって扱いづらいことがあります。駅距離のように右に長い分布では、log1p によって大きい値の影響を圧縮した方が関係を素直に読めることがあります。

fig, axes = plt.subplots(1, 2, figsize=(10, 3.6))

sns.histplot(df["distance_to_station"], bins=30, ax=axes[0], kde=True)
axes[0].set_title("original distance")

sns.histplot(np.log1p(df["distance_to_station"]), bins=30, ax=axes[1], kde=True)
axes[1].set_title("log1p(distance)")

plt.tight_layout()
plt.show()

3. 比較のためのベースライン

最初に単純な前処理だけでモデルを作っておくと、あとで加えた工夫の効果を測れます。特徴量設計は工夫の多さではなく、何を足した結果どれだけ変わったかで評価するべきです。

X = df.drop(columns=["price"])
y = df["price"]

X_train, X_test, y_train, y_test = train_test_split(
    X, y, test_size=0.2, random_state=42
)

num_cols = ["area", "rooms", "age", "distance_to_station", "floor"]
cat_cols = ["station"]

baseline_preprocess = ColumnTransformer([
    ("num", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]), num_cols),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore")),
    ]), cat_cols),
])

baseline_model = Pipeline([
    ("preprocess", baseline_preprocess),
    ("regressor", LinearRegression()),
])

baseline_model.fit(X_train, y_train)
baseline_pred = baseline_model.predict(X_test)

print(f"baseline MAE: {mean_absolute_error(y_test, baseline_pred):.2f}")
print(f"baseline R2 : {r2_score(y_test, baseline_pred):.3f}")

4. 意味のある特徴を足す

area_per_room は広さと間取りを同時に見た指標です。is_new は築浅の閾値効果を取り込み、log_distance は歪んだ距離分布を読みやすくします。

重要なのは、列を増やす理由を言語化できることです。

def add_features(frame: pd.DataFrame) -> pd.DataFrame:
    out = frame.copy()
    out["area_per_room"] = out["area"] / np.maximum(out["rooms"], 1)
    out["is_new"] = (out["age"] <= 5).astype(int)
    out["log_distance"] = np.log1p(out["distance_to_station"])
    return out

feature_num_cols = [
    "area", "rooms", "age", "distance_to_station", "floor",
    "area_per_room", "is_new", "log_distance"
]
feature_cat_cols = ["station"]

feature_preprocess = ColumnTransformer([
    ("num", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]), feature_num_cols),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore")),
    ]), feature_cat_cols),
])

feature_model = Pipeline([
    ("feature_builder", FunctionTransformer(add_features, validate=False)),
    ("preprocess", feature_preprocess),
    ("regressor", Ridge(alpha=1.0, random_state=42)),
])

feature_model.fit(X_train, y_train)
feature_pred = feature_model.predict(X_test)

print(f"feature MAE: {mean_absolute_error(y_test, feature_pred):.2f}")
print(f"feature R2 : {r2_score(y_test, feature_pred):.3f}")

5. 交互作用で表現力を増やす

線形モデルでも、交互作用や二乗項を入れると表現力はかなり増えます。反面、列数も急増するため、改善と同時に過学習の危険も高まります。

poly_num_cols = ["area", "rooms", "age", "distance_to_station", "floor"]

poly_preview_input = X_train[poly_num_cols].fillna(X_train[poly_num_cols].median())
poly_preview = PolynomialFeatures(degree=2, include_bias=False)
poly_preview.fit(poly_preview_input)
poly_names = poly_preview.get_feature_names_out(poly_num_cols)
print("num of polynomial features:", len(poly_names))
print("sample names:", poly_names[:12])

poly_preprocess = ColumnTransformer([
    ("num_poly", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
        ("poly", PolynomialFeatures(degree=2, include_bias=False)),
        ("scaler", StandardScaler()),
    ]), poly_num_cols),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore")),
    ]), cat_cols),
])

poly_model = Pipeline([
    ("preprocess", poly_preprocess),
    ("regressor", Ridge(alpha=2.0, random_state=42)),
])

poly_model.fit(X_train, y_train)
poly_pred = poly_model.predict(X_test)

print(f"poly MAE: {mean_absolute_error(y_test, poly_pred):.2f}")
print(f"poly R2 : {r2_score(y_test, poly_pred):.3f}")

6. 木モデルでは何が違うか

木ベースモデルは、スケーリングや単純な非線形性に比較的強く、線形モデルとは異なる仕方で特徴を使います。同じ特徴量設計でも、効き方がモデルごとに変わることがここで見えてきます。

tree_preprocess = ColumnTransformer([
    ("num", Pipeline([
        ("imputer", SimpleImputer(strategy="median")),
    ]), feature_num_cols),
    ("cat", Pipeline([
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("onehot", OneHotEncoder(handle_unknown="ignore")),
    ]), feature_cat_cols),
])

tree_model = Pipeline([
    ("feature_builder", FunctionTransformer(add_features, validate=False)),
    ("preprocess", tree_preprocess),
    ("regressor", RandomForestRegressor(
        n_estimators=300,
        max_depth=10,
        min_samples_leaf=2,
        random_state=42,
        n_jobs=-1,
    )),
])

tree_model.fit(X_train, y_train)
tree_pred = tree_model.predict(X_test)

print(f"random forest MAE: {mean_absolute_error(y_test, tree_pred):.2f}")
print(f"random forest R2 : {r2_score(y_test, tree_pred):.3f}")

7. もっとも危険なのはリーク

特徴量設計で致命的なのは、テスト側の情報を前処理へ混ぜることです。欠損補完やスケーリングの fit をどこで行うかまで含めて設計しないと、見かけだけ高いスコアが簡単に生まれます。

models = {
    "baseline_linear": baseline_model,
    "feature_ridge": feature_model,
    "poly_ridge": poly_model,
    "tree_rf": tree_model,
}

rows = []
for name, model in models.items():
    # すべての前処理(特徴量追加を含む)をパイプライン内で実行
    scores = cross_val_score(
        model,
        X,
        y,
        cv=5,
        scoring="neg_mean_absolute_error",
        n_jobs=-1,
    )
    rows.append({
        "model": name,
        "cv_mae_mean": -scores.mean(),
        "cv_mae_std": scores.std(),
    })

pd.DataFrame(rows).sort_values("cv_mae_mean")

まとめ

特徴量設計の本質は、思いついた列を増やすことではありません。元データを観察し、基準線を置き、意味のある変換だけを追加し、その変化を比較可能な形で読むことにあります。

モデル選択より前に、入力表現の質が学習の上限をかなり決めていることを押さえておく必要があります。