0 はじめに
0.1 この記事の目的
今回はKaggleの有名なコンペティション、House Pricesに挑戦します。
このコンペ、あまりにも有名すぎて調べるといくらでも解説などが載っています。しかし、多くの解説が「これが上手くいきます」以上の説明がなく、その手前の「どんなことを試してみたら上手く行かなかったのか」や「その失敗からなぜそのような作業をしたのか」という説明が少ないと思います。
なので、この記事では「データをのぞいてみる」ところから、可能な限り全ての作業を一つずつ「なぜそうしたのか」の説明もしながらHouse Pricesコンペティションを進めていきたいと思います。
言うなればこの記事は「コンペの挑戦実況」を書き起こしたものです。ですから、とても冗長な記事なる上に、つど話数が分かれていくと思います。悪しからず。
0.2 筆者について
筆者は2025年4月1日から機械学習エンジニアにキャリアチェンジをした、完全なる初心者です。前職も全く関係のないことをしていました。ですから、この記事で書き起こしたコードや考え方には間違いや瑕疵、非効率的な部分が多分にあるかと思います。また、必ずしも業界的なベストプラクティスに沿ったものではないです。ご了承ください。
0.3 環境
OS: Linux (WSL - Ubuntu)
Jupyter Notebook (VSCode)
Python 3.12.3
1 セットアップ
まじでここから言語化していきます。
まずはプロジェクト用のディレクトリを作成し、移動。
mkdir -p kaggle-house-price/{data/{raw,processed,image},notebooks/{utils,constants},models}
cd kaggle-house-price
大体この階層にしています。dataは無加工と処理済み、それからmatplotlibなどで視覚化したデータで分けられるようにしています。
notebooksの中には、使いまわせる関数や定数を入れておくutilsモジュールとconstantsモジュールを作っています。constantsは特定の特徴量だけを毎回取得したいときなんかにカラムリストを保存しておいたり、またパスを保存したりしています。モジュールを作るだけ作って使わないことも多々あります。
また、二段階モデリングの場合などで学習済みモデルを保存したりそこから呼び出すためのmodelsも作っておきます。
次に、仮想環境を作成して仮想環境内に入ります。
python -m venv .venv
source .venv/bin/acitvate
ほぼ確定で使うパッケージをインストール。
pip install numpy pandas scikit-learn jupyter matplotlib seaborn
今回は、最近勉強した勾配ブースティングを使ってみようかなーと思ったので、XGBoostも追加しました。
VSCodeで開きます。
code .
準備は整いました。
2 EDA(Exploratory Data Analysis:探索的データ分析)
2.1 ノートブック設定
まずは探索用のノートブックを作成。
touch notebooks/{0000_eda.ipynb,0001_process.ipynb,0100_baseline.ipynb}
探索、前処理、ベースラインモデル(最初に作る、基準となるモデル)、各モデルで分けて作ることが多いです。また、探索と前処理を同時に開いておき、探索中に役立ったデータ整形を即時に前処理に反映させたりしています。
また、格ノートブックには自分なりのわかりやすい4桁コードをつけています。上2桁は用途・モデルごとに分け、下二桁を順番にしています。
上2桁 | 用途・モデル |
---|---|
00 | データ探索・処理など |
01 | ベースライン |
02 | [以下、モデルごとにナンバリング] |
$\vdots$ | $\vdots$ |
0000_eda.ipynbを開いたら、まずは必要なモジュールをインポート。
先頭のセルで必要なモジュールをインポートしておくことで、ノートブックの途中でカーネルを再起動したときも先頭だけ再読み込みすれノートブックの途中からコードを書いていくことができます。
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
plt.style.use('ggplot')
sns.set_palette('Set2')
下の2行で図表の色を指定していますが、これは好みの問題なので別になくてもいいです。
2.2 データの概観を確認
Kaggleのコンペティションページから取得したデータを開き、とりあえず中身を確認します。
train = pd.read_csv('../data/raw/train.csv', index_col=0)
train.head()
# 別のセルで
train.info()
# OUTPUT
# Column Non-Null Count Dtype
--- ------ -------------- -----
0 MSSubClass 1460 non-null int64
1 MSZoning 1460 non-null object
2 LotFrontage 1201 non-null float64
3 LotArea 1460 non-null int64
4 Street 1460 non-null object
5 Alley 91 non-null object
6 LotShape 1460 non-null object
7 LandContour 1460 non-null object
8 Utilities 1460 non-null object
9 LotConfig 1460 non-null object
10 LandSlope 1460 non-null object
11 Neighborhood 1460 non-null object
12 Condition1 1460 non-null object
13 Condition2 1460 non-null object
14 BldgType 1460 non-null object
15 HouseStyle 1460 non-null object
16 OverallQual 1460 non-null int64
17 OverallCond 1460 non-null int64
18 YearBuilt 1460 non-null int64
19 YearRemodAdd 1460 non-null int64
...
78 SaleCondition 1460 non-null object
79 SalePrice 1460 non-null int64
dtypes: float64(3), int64(34), object(43)
memory usage: 923.9+ KB
80の変数と1460のサンプルがあるそうです。ひえー。
目的変数である家の販売価格の分布表を見てみます。
sns.histplot(train['SalePrice'], kde=True)
train['SalePrice_log'] = np.log(train['SalePrice'])
plt.figure(figsize=(12,8))
sns.histplot(train['SalePrice_log'], kde=True)
plt.show()
target = train['SalePrice']
target_log = ttain['SalePrice_log']
target_log.skew(), target_log.kurtosis()
# 出力
# (np.float64(0.12133506220520406), np.float64(0.8095319958036296))
だいぶいい感じなので、この値を予測した上で、提出時に指数変換しようと思います。
2.3 思いつく限り特徴量を作ってみる
次に説明変数を見ていきたいのですが、このコンペのデータセットは特徴量が80個くらいあってかなり面倒くさそうです。自分が家を選ぶときの条件を参考にいくつか見当をつけた上で、目的変数との関係を見てみようと思います。
コンペの特徴量の中で特に重要そうなのは、
- 立地:Neighborhood
- 家の状態:OverallQual, OverallCond, ExterQual, ExterCond
- 広さ:LotArea, 1stFlrSF, 2ndFlrSF, GrLivArea
- 古さ:YearBuilt, YearRemodAdd
これらと対数価格の関係を見てみたいのだけど、Neighborhoodが文字列データかつアメリカのどこかの地名なので土地勘もない。
そこで、立地ごとに価格の平均を求めて、序列をつけてみる。
立地の特徴量
neighbor_price = train.groupby('Neighborhood')['SalePrice'].mean().sort_values(ascending=False)
neighbor_price
- 出力
Neighborhood
NoRidge 335295.317073
NridgHt 316270.623377
StoneBr 310499.000000
Timber 242247.447368
.
.
.
この順番をこのままナンバリングして順序尺度化してもいいのだけど、NoRidgeとNridgHtの平均値の差がStoneBrとTimberの差と同じとは限らないので、min-max法で係数化してみる。
from sklearn.preprocessing import MinMaxScaler
mm_scaler = MinMaxScaler()
neighbor_price_mmsclaled = mm_scaler.fit_transform(neighbor_price.values.reshape(-1, 1))
neighbor_price_dict = dict(zip(neighbor_price.index, neighbor_price_mmsclaled))
neighbor_price_dict
- 出力
'NoRidge': array([1.]),
'NridgHt': array([0.91963169]),
'StoneBr': array([0.89524992]),
'Timber': array([0.60692665]),
.
.
.
'BrkSide': array([0.11092307]),
'BrDale': array([0.02499708]),
'IDOTRR': array([0.0065365]),
'MeadowV': array([0.])}
一応これをカラムとして保存
train['NeighborhoodPrice_mmscaled'] = train['Neighborhood'].map(neighbor_price_dict).astype(float)
これとは別に、対数変換した価格の平均を立地ごとに集計し、こちらは標準化により係数化して、これも使えそうかもと思い、カラムとして保存。
from sklearn.preprocessing import StandardScaler
std_scaler = StandardScaler()
neighbor_log_price = train.groupby('Neighborhood')['LogPrice'].mean().sort_values(ascending=False)
neighbor_log_price_stdsclaled = std_scaler.fit_transform(neighbor_log_price.values.reshape(-1, 1))
neighbor_log_price_dict = dict(zip(neighbor_log_price.index, neighbor_log_price_stdsclaled))
train['NeighborhoodLogPrice_stdscaled'] = train['Neighborhood'].map(neighbor_log_price_dict).astype(float)
中身はこんな感じ。
{'NoRidge': array([1.89799433]),
'NridgHt': array([1.73171346]),
'StoneBr': array([1.63202722]),
.
.
.
'BrDale': array([-1.41692402]),
'MeadowV': array([-1.63243298]),
'IDOTRR': array([-1.71363052])}
こうして使えそうなものは全部取っておいて、あれこれモデルにぶち込んで一番良いモデルを採用していこうと思います。
また、こうして特徴量を追加したときは、data/
内にadded_deatures.md
などを作って追加した特徴量とその内容をメモしておき、後かからどの特徴量がいつどんな処理をしたものかがすぐわかるようにしています。
# 0000_EDA
- log_price: 'SalePrice'を自然対数変換したもの。
- neighbor_price_mmscaled: 'Neighborhood'ごとに'SalePrice'の平均を集計し、min-max法により立地を係数化したもの。
- neighbor_log_price_stdscaled: 'Neighborhood'ごとに'LogPrice'の平均を集計し、標準化により立地を係数化したもの。
この処理がかなり使えそうだったので、utils.pyに関数化しようと思います。
# notbooks/utils/utils.py
def ordinal_to_weight(df: pd.DataFrame, col_name: str, target: str='SalePrice', log_transform=False, scaler_type: str='minmax') -> pd.DataFrame | dict:
"""
順序尺度のカテゴリを対象変数の平均値に基づいて数値化する関数
Args:
df (DataFrame): 対象データフレーム
col_name (str): 順序尺度の列名
target (str, optional): Defaults to 'SalePrice'.
log_transform (bool, optional): 対数変換するかどうか. Defaults to False.
scale_typy (str): スケーリング方法('standard', 'minmax'). Defaults to 'minmax'.
Returns:
DataFrame: 新しい特徴量が追加されたデータフレーム
dict: 変換用の辞書(テストデータ用)
"""
df_copy = df.copy()
# 対数変換の処理
if log_transform and target == 'SalePrice':
target_col = f'Log{target}'
if target_col not in df_copy.columns:
df_copy[target_col] = np.log(df_copy[target])
else:
target_col = target
# 各カテゴリの平均値を計算
category_weight = df_copy.groupby(col_name)[target_col].mean()
# スケーリング
if scaler_type == 'standard':
from sklearn.preprocessing import StandardScaler
scaler = StandardScaler()
elif scaler_type == 'minmax':
from sklearn.preprocessing import MinMaxScaler
scaler = MinMaxScaler()
else:
raise ValueError('scale_type must be "standard" or "minmax"')
if scaler_type != 'none':
scaled_values = scaler.fit_transform(category_weight.values.reshape(-1, 1))
category_weight_dict = dict(zip(category_weight.index, scaled_values))
if log_transform and target == 'SalePrice':
new_col_name = f'Log{col_name}Weight'
df_copy[new_col_name] = df_copy[col_name].map(category_weight_dict)
else:
new_col_name = f'{col_name}Weight'
df_copy[new_col_name] = df_copy[col_name].map(category_weight)
return df_copy, category_weight_dict
この関数を使ってジャカジャカ順序尺度を係数化していきます。
順序尺度を係数化
他に、(勘で)ある程度価格と相関が強そうな順序尺度は、
- OverallQual: 住宅の材質と仕上げの質(1-10)
- OverallCond: 住宅の全体的な状態(1-10)
- ExterQual: 外装の材質の質(Ex, Gd, TA, Fa, Po)
- ExterCond: 外装の現在の状態(Ex, Gd, TA, Fa, Po)
- BsmtQual: 地下室の高さの評価(Ex, Gd, TA, Fa, Po, NA)
- BsmtCond: 地下室の一般的な状態(Ex, Gd, TA, Fa, Po, NA)
- BsmtFinType1: 地下室の仕上げエリアの評価(GLQ, ALQ, BLQ, Rec, LwQ, Unf, NA)
- BsmtFinType2: 2つ目の仕上げエリアの評価(同上)
- HeatingQC: 暖房の品質と状態(Ex, Gd, TA, Fa, Po)
- KitchenQual: キッチンの品質(Ex, Gd, TA, Fa, Po)
- FireplaceQu: 暖炉の品質(Ex, Gd, TA, Fa, Po, NA)
- GarageFinish: ガレージの内装仕上げ(Fin, RFn, Unf, NA)
- GarageQual: ガレージの品質(Ex, Gd, TA, Fa, Po, NA)
- GarageCond: ガレージの状態(Ex, Gd, TA, Fa, Po, NA)
- PoolQC: プールの品質(Ex, Gd, TA, Fa, NA)
- Fence: フェンスの品質(GdPrv, MnPrv, GdWo, MnWw, NA)
- Functional: 住宅の機能性(Typ, Min1, Min2, Mod, Maj1, Maj2, Sev, Sal)
品質評価っぽいものは、そのまま順序尺度として扱ったり、One-Hotエンコーディングするより係数化した方が扱いやすそうな勘が働いています(決定木とかはそのままでも良さそう)。
面倒なので、ループで全部係数化します。意味がないものもあるかもしれませんが、特徴量を作るだけ作ってからモデルに突っ込んだり主成分分析で次元を削減したり相関図を見て取捨選択すればいいやの精神です。
NaN値があると上手く係数化できないので、NaNを文字列に処理してから、係数を作っていきます。
ordinal_cols = ['OverallQual', 'OverallCond', 'ExterQual', 'ExterCond',
'BsmtQual', 'BsmtCond', 'BsmtFinType1', 'BsmtFinType2',
'HeatingQC', 'KitchenQual', 'FireplaceQu',
'GarageFinish', 'GarageQual', 'GarageCond', 'PoolQC',
'Fence', 'Functional']
# 欠損値を'NA'に変換
for col in ordinal_cols:
if col in train.columns:
train[col] = train[col].fillna('NA')
weight_dicts = {}
from utils import ordinal_to_weight
# 対数価格についての標準化係数を取得
for col in ordinal_cols:
if col in train.columns:
train, weight_dict = ordinal_to_weight(df=train, col_name=col, log_transform=True, scaler_type='standard')
weight_dicts[col] = weight_dict
print(f"{col} を重み付けしました。")
# 価格についてmin-max係数化
for col in ordinal_cols:
if col in train.columns:
train, weight_dict = ordinal_to_weight(df=train, col_name=col)
weight_dicts[col] = weight_dict
print(f"{col} を重み付けしました。")
# 確認
train.head()
ここまでの作業で、
- 立地:Neighborhood
- 家の状態:OverallQual, OverallCond, ExterQual, ExterCond
ここまで処理が終わりました。次は、以下を考えていきます。
- 広さ:LotArea, 1stFlrSF, 2ndFlrSF, GrLivArea
- 古さ:YearBuilt, YearRemodAdd
広さの特徴量作成
広さについては、敷地面積(LotArea)、階ごとの面積(1stFlrSF, 2ndFlrSF, TotalBsmtSF)、居住可能面積と、かなりかぶっている感があります。上手く圧縮できそうです。
まずは、居住面積に関わる変数同士の相関行列(ヒートマップ)と散布図を見ていきます。
area_features = [
'1stFlrSF', '2ndFlrSF', 'TotalBsmtSF',
'GrLivArea', 'LotArea', 'GarageArea'
]
# 相関行列のヒートマップ
plt.figure(figsize=(10, 8))
area_corr = train[area_features].corr()
sns.heatmap(area_corr, annot=True, cmap='coolwarm')
plt.title('Correlation Matrix: Area Features')
plt.show()
# 相互散布図
sns.pairplot(train[area_features], diag_kind='kde')
plt.show()
ヒートマップからは、次のようなことが読み取れそうです。
- 1階と地下室の強い相関:地下室は1階の真下にあることが多いから、当然か。
- 1階と2階は逆相関:2階建ての家は各階が小さめで平屋は1階が広めっていう傾向がありそう。
- 総居住面積への寄与度:総居住面積(GrLivArea)に対して、2階の面積の相関(0.69)の方が1階のそれ(0.57)より高いのは面白い。2階の有無が総面積の変動に大きく効いてそう。
- 総敷地面積(LotArea)は他とあまり相関しない:敷地面積は建物面積とあまり強くは相関していない(0.3前後)。高級住宅地では敷地が広くても家は小さかったり、逆に敷地が狭くても縦に大きく建てたりがあるから当然か。
これらを踏まえて、床面積因子のような特徴量を作成。また、2階の有無、地下室の有無、ガレージの有無に対してフラグを設定する。
# 床面積因子を作成
train['TotalSF'] = train['1stFlrSF'] + train['2ndFlrSF'].fillna(0) + train['TotalBsmtSF'].fillna(0)
# 2階、地下室、ガレージ、プールのうむのフラグを設定
train['has_2nd'] = (train['2ndFlrSF'] > 0).astype(int)
train['has_bsmt'] = (train['TotalBsmtSF'] > 0).astype(int)
train['has_garage'] = (train['GarageArea'] > 0).astype(int)
train['has_pool'] = (train['PoolArea'] > 0).astype(int)
また、ペアプロットからは、地下室や2階がない建物もあることで、密度曲線が左に寄ったり山が二つできたりしている。面積に関わる変数は全体的に右に裾が長いので、こちらもガンガン対数変換していく。
floor_features = [
'1stFlrSF', '2ndFlrSF', 'TotalBsmtSF',
'GrLivArea', 'LotArea', 'TotalFlr'
]
for col in floor_features:
train['Log' + col] = np.log(train[col]+1)
古さの特徴量作成
次に、建物の古さの特徴量を作っていきます。
まずは、販売時の築年数と、リフォームまでの経過年の特徴量を作成します。
# 販売年 - 建築年
train['house_age'] = train['YrSold'] - train['YearBuilt']
# 販売年 - 改築年
train['remod_age'] = train['YrSold'] - train['YearRemodAdd']
築年代ごとのカテゴリを作成しようと思いますが、カテゴリの範囲を調べるために度数分布表を作ります。
度数分布表を作成する関数をutils内に作り(参考:「Pythonで度数分布表を一発で自動生成する」 @TakuTaku36)、年代ごとの度数分布を確認します。
# utils.py
def freq_dist(data, class_width=None):
""" 度数分布表を作成する関数 """
data = np.asarray(data)
if class_width is None:
class_size = int(np.log2(data.size).round()) + 1
class_width = round((data.max() - data.min()) / class_size)
bins = np.arange(0, data.max()+class_width+1, class_width)
hist = np.histogram(data, bins)[0]
cumsum = hist.cumsum()
return pd.DataFrame({'階級値': (bins[1:] + bins[:-1]) / 2,
'度数': hist,
'累積度数': cumsum,
'相対度数': hist / cumsum[-1],
'累積相対度数': cumsum / cumsum[-1]},
index=pd.Index([f'{bins[i]}以上{bins[i+1]}未満'
for i in range(hist.size)],
name='階級'))
# notebook内
year_freq = freq_dist(train['YearBuilt'], class_width=10)
year_freq.iloc[187:] # 1870年以降しかデータがないため
大体1900年以前、1900~1940年、以降20年区切り、2000年以降をまとめればよさそう。
train['built_era'] = pd.cut(
train['YearBuilt'],
bins=[1800, 1900, 1940, 1960, 1980, 2000, 2020],
labels=['pre_1900', '1900-1940', '40-60', '60-80', '80-2000', 'post-2000']
)
また、住宅のふるさと価格の関係も重要な情報なので、年代別の平均価格を作る。平滑化するために、ローリング平均をここでは使用する。
# 建築年ごとの平均価格
year_price = train.groupby('YearBuilt')['SalePrice'].mean()
# ローリング平均
year_price = year_price.rolling(window=5, center=True).mean()
# 欠損値を前後の値で補完
year_price = year_price.interpolate()
# DataFrameに追加
train['year_price'] = train['YearBuilt'].map(year_price)
まとめ
ここまでで、価格に特に影響を与えそうな以下の特徴量を作成しました。
- 立地
- 家の状態
- 広さ
- 古さ
次回は、これらの特徴量をもとにベースラインモデルを作り、貢献度の評価を見てさらに特徴量を追加または削除していこうと思います。
追加した特徴量一覧
# 0000_EDA
- log_price: 'SalePrice'を自然対数変換したもの。
- neighbor_price_mmscaled: 'Neighborhood'ごとに'SalePrice'の平均を集計し、min-max法により立地を係数化したもの。
- neighbor_log_price_stdscaled: 'Neighborhood'ごとに'LogPrice'の平均を集計し、標準化により立地を係数化したもの。
- total_sf = 1stFlrSF + 2ndFlrSF (nan = 0) + TotalBsmtSF (nan = 0)
- 下記のそれぞれを重みづけ: log_は対数価格に対する標準化指標
'OverallQual', 'OverallCond', 'ExterQual', 'ExterCond',
'BsmtQual', 'BsmtCond', 'BsmtFinType1', 'BsmtFinType2',
'HeatingQC', 'KitchenQual', 'FireplaceQu',
'GarageFinish', 'GarageQual', 'GarageCond', 'PoolQC',
'Fence', 'Functional'
- has_2nd: 2階の有無フラグ
- has_bsmt: 地下室の有無フラグ
- has_garage: ガレージの有無フラグ
- has_pool: プールの有無フラグ
- house_age: 販売年 - 建築年
- 以下の特徴量に対し、対数変換した特徴量を追加: log_
'1stFlrSF', '2ndFlrSF', 'TotalBsmtSF',
'GrLivArea', 'LotArea', 'total_sf'
- remod_age: 販売年 - 改築年
- yrs_before_remod: 改築年 - 建築年
- is_remodeled: 改築の有無フラグ
- built_era: 1900年以前、1900~1940年、以降20年区切り、2000年以降で建築年代をカテゴライズ
- year_price: 建築年ごとの住宅価格のローリング平均