LoginSignup
0
1

More than 1 year has passed since last update.

Mahalanobis distanceをsample_weightにする 備忘録

Posted at

概要

imbalancedなclassification問題のsample_weight計算方法を調べてて、regressionならどうなるのだろう、と疑問に感じた。classificationの場合、classの出現頻度の逆数をweightとする。つまり稀なクラスほどweightは大きくなる。

これをregressionに当てはめるなら稀な値、つまりanomalyなyほどweightを大きくすればいいんじゃないかと。そうすると特異検知の話になるのでsample_weightをマハラノビス距離にしてみたらどうだろう、というのが今回の実験備忘録。

classification用のsample_weight計算方法は良い記事を見つけたので、それを試した備忘録を後日投稿することとする。

  • 実施期間: 2023年2月
  • 環境:Colab
  • パケージ:scikit-learn

1. Dataset

sklearnのcalifornia_housingを使用する。
まず、最近お世話になることが多いfeature-engineをinstallしておく。

!pip install feature-engine

importとdatasetのloadまで行う。

import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
from sklearn import datasets, linear_model
from sklearn.pipeline import Pipeline
from sklearn.model_selection import train_test_split
from sklearn.ensemble import RandomForestRegressor
from sklearn.metrics import mean_squared_error, r2_score
from feature_engine.wrappers import SklearnTransformerWrapper

# DataFrameでloadする。
df_org = datasets.fetch_california_housing(return_X_y=False, as_frame=True).frame

Kaggleでもほぼ同じ内容のcalifornia housing問題があって、そのときに検討した処理をする。sample_weightとは関係しないが、この処理も備忘録として残す。
このdatasetの扱いに困るのがLatitudeとLongitudeだと思う。下図のように完全に2つの山があり、これを直感的に36度線で切ると山を分離することもできるがLatitudeの配慮が全く無くないのでイヤ。

fig, axes = plt.subplots(1, 2, figsize=(8, 4), tight_layout=True)  # , sharex="all"
df_org['Latitude'].hist(bins=20, ax=axes[0])
axes[0].set_title('Latitude')
df_org['Longitude'].hist(bins=20, ax=axes[1])
axes[1].set_title('Latitude')
plt.show()

image.png

やはりML的に分離したいのでGMMで2つに分離する。ヒューリスティックな36度線分離でヒストグラムはきれいになったので、それぞれの山はガウス分布であると仮定した。
便宜的にcordinate_labelをDataFrameに追加し2つを識別する。

from sklearn.mixture import GaussianMixture

# train, validation, testに分ける。
df = df_org.sample(frac=1, random_state=42, ignore_index=True)
train_df = df.iloc[:10000]
val_df = df.iloc[10000:20000]
test_df = df.iloc[20000:]

# train_dfでfitさせ、
gm = GaussianMixture(n_components=2, n_init=10, random_state=42)
gm.fit(train_df[['Longitude', 'Latitude']])

# fit()で収束したか確認する。
# print(f'Convergence: {gm.converged_}')

# train, validation, testのそれぞれで分離する。
y_pred = gm.predict(train_df[['Longitude', 'Latitude']])
y_pred_prob = gm.predict_proba(train_df[['Longitude', 'Latitude']])
train_df = train_df.assign(cordinate_label = y_pred)
y_pred  = gm.predict(val_df[['Longitude', 'Latitude']])
val_df = val_df.assign(cordinate_label = y_pred)
y_pred  = gm.predict(test_df[['Longitude', 'Latitude']])
test_df = test_df.assign(cordinate_label = y_pred)

train_df.head()

Screenshot from 2023-02-05 21-35-30.png

fig, axis = plt.subplots(1, 2, figsize=(16, 8), tight_layout=True)
train_df[train_df['cordinate_label'] == 0].plot.scatter(x='Longitude', y='Latitude', c='r', ax = axis[0])
train_df[train_df['cordinate_label'] == 1].plot.scatter(x='Longitude', y='Latitude', c='g', ax = axis[0])

val_df[val_df['cordinate_label'] == 0].plot.scatter(x='Longitude', y='Latitude', c='r', ax = axis[1])
val_df[val_df['cordinate_label'] == 1].plot.scatter(x='Longitude', y='Latitude', c='g', ax = axis[1])

image.png

LAを中心にする赤点軍と、SFを中心とする緑点群に別れた。
直感的には赤点になってもいいような緑点も内陸部にあるけどsample_weightを検討するには逆にいいのかもしれない。まぁ、境界線があることは明らかに見えるし、ここにはあまり手を入れないほうがいい気がする。

Longitude, Latitudeの分布を確認する。

fig, axes = plt.subplots(2, 2, figsize=(10, 8), tight_layout=True)  # , sharex="all"
train_df[train_df['cordinate_label']==0]['Latitude'].hist(bins=20, ax=axes[0, 0])
axes[0, 0].set_title('Latitude_r')
train_df[train_df['cordinate_label']==0]['Longitude'].hist(bins=20, ax=axes[0, 1])
axes[0, 1].set_title('Longitude_r')

train_df[train_df['cordinate_label']==1]['Latitude'].hist(bins=20, ax=axes[1, 0])
axes[1, 0].set_title('Latitude_g')
train_df[train_df['cordinate_label']==1]['Longitude'].hist(bins=20, ax=axes[1, 1])
axes[1, 1].set_title('Longitude_g')

image.png

座標系を2つに分け、

def divide_into_2regions(df):
    # red: LA
    df['Latitude_r'] = df[df['cordinate_label']==0]['Latitude']
    df['Longitude_r'] = df[df['cordinate_label']==0]['Longitude']
    df['cordinate_label_r'] = (df['cordinate_label']==0)
    # green: San Francisco
    df['Latitude_g'] = df[df['cordinate_label']==1]['Latitude']
    df['Longitude_g'] = df[df['cordinate_label']==1]['Longitude']
    df['cordinate_label_g'] = (df['cordinate_label']==1)
    df = df.drop(['Latitude', 'Longitude', 'cordinate_label'], axis=1)
    return df


train_df = divide_into_2regions(train_df)
val_df = divide_into_2regions(val_df)
test_df = divide_into_2regions(test_df)
train_df.head()

Screenshot from 2023-02-06 21-16-43.png

さらにredのみ、greenのみのdatasetをこしらえ、とりあえずgreenのdatasetだけを使ってsample_weightの評価を行う。

train_df_r = train_df[train_df['cordinate_label_r']==True]
train_df_r = train_df_r.drop(['Latitude_g', 'Longitude_g', 'cordinate_label_g'], axis=1)
train_df_g = train_df[train_df['cordinate_label_g']==True]
train_df_g = train_df_g.drop(['Latitude_r', 'Longitude_r', 'cordinate_label_r'], axis=1)
val_df_r = val_df[val_df['cordinate_label_r']==True]
val_df_r = val_df_r.drop(['Latitude_g', 'Longitude_g', 'cordinate_label_g'], axis=1)
val_df_g = val_df[val_df['cordinate_label_g']==True]
val_df_g = val_df_g.drop(['Latitude_r', 'Longitude_r', 'cordinate_label_r'], axis=1)
test_df_r = test_df[test_df['cordinate_label_r']==True]
test_df_r = test_df_r.drop(['Latitude_g', 'Longitude_g', 'cordinate_label_g'], axis=1)
test_df_g = test_df[test_df['cordinate_label_g']==True]
test_df_g = test_df_g.drop(['Latitude_r', 'Longitude_r', 'cordinate_label_r'], axis=1)

分布を確認する。

def draw_all_hist(df, region):
    fig, axes = plt.subplots(2, 4, figsize=(20, 8), tight_layout=True)  # , sharex="all"
    df['MedInc'].hist(bins=20, ax=axes[0, 0])
    axes[0, 0].set_title('MedInc')

    df['HouseAge'].hist(bins=20, ax=axes[0, 1])
    axes[0, 1].set_title('HouseAge')

    df['AveRooms'].hist(bins=20, ax=axes[0, 2])
    axes[0, 2].set_title('AveRooms')

    df['AveBedrms'].hist(bins=20, ax=axes[0, 3])
    axes[0, 3].set_title('AveBedrms')

    df['Population'].hist(bins=20, ax=axes[1, 0])
    axes[1, 0].set_title('Population')

    df['AveOccup'].hist(bins=20, ax=axes[1, 1])
    axes[1, 1].set_title('AveOccup')

    if region == 'r':
        df['Latitude_r'].hist(bins=20, ax=axes[1, 2])
        axes[1, 2].set_title('Latitude_r')

        df['Longitude_r'].hist(bins=20, ax=axes[1, 3])
        axes[1, 3].set_title('Longitude_r')
    else:
        df['Latitude_g'].hist(bins=20, ax=axes[1, 2])
        axes[1, 2].set_title('Latitude_g')

        df['Longitude_g'].hist(bins=20, ax=axes[1, 3])
        axes[1, 3].set_title('Longitude_g')

    plt.show()

draw_all_hist(train_df_g, 'g')

image.png

Outlierを除去する場合
def find_normal_boundaries(df, variable):
    # Calculate the boundaries
    # for a Gaussian distribution.
    upper_boundary = df[variable].mean() + 3 * df[variable].std()
    lower_boundary = df[variable].mean() - 3 * df[variable].std()
    return upper_boundary, lower_boundary


def find_skewed_boundaries(df, variable, distance):
    # Let's calculate the boundaries
    # for skewed distributions
    # The parameter "distance" gives us the option to
    # estimate 1.5 times or 3 times the IQR when defining
    # the boundaries.
    IQR = df[variable].quantile(0.75) - df[variable].quantile(0.25)
    lower_boundary = df[variable].quantile(0.25) - (IQR * distance)
    upper_boundary = df[variable].quantile(0.75) + (IQR * distance)
    return upper_boundary, lower_boundary

'AveRooms', 'AveBedrms', 'Population', 'AveOccup', 'Longitude_g'は外れ値があるので、IQRの3倍以上外れた点を除去する。

train_df_g_no_out = train_df_r.copy()
high_bnd, low_bnd = find_skewed_boundaries(train_df_g_no_out, 'AveRooms', 3.)
train_df_g_no_out = train_df_g_no_out[(train_df_g_no_out['AveRooms']>low_bnd) & (train_df_g_no_out['AveRooms']<high_bnd)]
high_bnd, low_bnd = find_skewed_boundaries(train_df_g_no_out, 'AveBedrms', 3.)
train_df_g_no_out = train_df_g_no_out[(train_df_g_no_out['AveBedrms']>low_bnd) & (train_df_g_no_out['AveBedrms']<high_bnd)]
high_bnd, low_bnd = find_skewed_boundaries(train_df_g_no_out, 'Population', 3.)
train_df_g_no_out = train_df_g_no_out[(train_df_g_no_out['Population']>low_bnd) & (train_df_g_no_out['Population']<high_bnd)]
high_bnd, low_bnd = find_skewed_boundaries(train_df_g_no_out, 'AveOccup', 3.)
train_df_g_no_out = train_df_g_no_out[(train_df_g_no_out['AveOccup']>low_bnd) & (train_df_g_no_out['AveOccup']<high_bnd)]
high_bnd, low_bnd = find_skewed_boundaries(train_df_g_no_out, 'Longitude_g', 3.)
train_df_g_no_out = train_df_g_no_out[(train_df_g_no_out['Longitude_g']>low_bnd) & (train_df_g_no_out['Longitude_g']<high_bnd)]

train_df_g_no_out.describe()
print(f'{len(train_df_g) - len(train_df_g_no_out)} samples were omitted.')

除去後の分布を確認する。

draw_all_hist(train_df_g_no_out, 'g')

不要な'cordinate_label_g'列を落とし、train, val, test datasetについてそれぞれX, yに分ける。

train_df_g_no_out = train_df_g_no_out.drop(['cordinate_label_g'], axis=1)
train_X = train_df_g_no_out.drop(['MedHouseVal'], axis=1)
train_y = train_df_g_no_out['MedHouseVal']

val_df_g_no_out = val_df_g.copy().drop(['cordinate_label_g'], axis=1)
val_X = val_df_g_no_out.drop(['MedHouseVal'], axis=1)
val_y = val_df_g_no_out['MedHouseVal']

test_df_g_no_out = test_df_g.copy().drop(['cordinate_label_g'], axis=1)
test_X = test_df_g_no_out.drop(['MedHouseVal'], axis=1)
test_y = test_df_g_no_out['MedHouseVal']

不要な'cordinate_label_g'列を落とし、train, val, test datasetについてそれぞれX, yに分ける。

# greenの場合は下記を実行
train_df_g_no_out = train_df_g.copy().drop(['cordinate_label_g'], axis=1)
train_X = train_df_g_no_out.drop(['MedHouseVal'], axis=1)
train_y = train_df_g_no_out['MedHouseVal']

val_df_g_no_out = val_df_g.copy().drop(['cordinate_label_g'], axis=1)
val_X = val_df_g_no_out.drop(['MedHouseVal'], axis=1)
val_y = val_df_g_no_out['MedHouseVal']

test_df_g_no_out = test_df_g.copy().drop(['cordinate_label_g'], axis=1)
test_X = test_df_g_no_out.drop(['MedHouseVal'], axis=1)
test_y = test_df_g_no_out['MedHouseVal']

y('MedHouseVal')を確認する。

train_y.sort_values(ignore_index=True).plot()

image.png

2. Scale用のPipe line

Feature-engineを積極的に使用する。

from feature_engine.transformation import LogTransformer, BoxCoxTransformer
from feature_engine.outliers import Winsorizer
from sklearn.preprocessing import StandardScaler, Normalizer

# from sklearn.preprocessing import QuantileTransformer
# quantile_transformer = QuantileTransformer(output_distribution='normal', random_state=0)

各featureの分布から、pipe lineは下記とする。
Feature-engineで用意されていない変換はsklearnを使用するが、これもSklearnTransformerWrapper()でラップすることによりDataFrameをシームレスに使用できる。

all_clmns = list(train_X.columns)

preprocessor = Pipeline([
    ('iqr_cap_right', Winsorizer(capping_method='iqr', tail='right', fold=3, variables=['MedInc', 'AveRooms', 'AveBedrms', 'Population', 'AveOccup'])),
    ('iqr_cap_both', Winsorizer(capping_method='iqr', tail='both', fold=3, variables=['Longitude_g'])), # , add_indicators=True
    ('boxcox', BoxCoxTransformer(variables=['MedInc', 'Population', 'AveOccup'])),
    ('std_scaler', SklearnTransformerWrapper(transformer = StandardScaler(), variables = all_clmns)),
    # ('norm_scaler', SklearnTransformerWrapper(transformer = Normalizer(), variables = all_clmns)),
])

preprocessor.fit(train_X)
Pipeline(steps=[('iqr_cap_right',
                 Winsorizer(capping_method='iqr',
                            variables=['MedInc', 'AveRooms', 'AveBedrms',
                                       'Population', 'AveOccup'])),
                ('iqr_cap_both',
                 Winsorizer(capping_method='iqr', tail='both',
                            variables=['Longitude_g'])),
                ('boxcox',
                 BoxCoxTransformer(variables=['MedInc', 'Population',
                                              'AveOccup'])),
                ('std_scaler',
                 SklearnTransformerWrapper(transformer=StandardScaler(),
                                           variables=['MedInc', 'HouseAge',
                                                      'AveRooms', 'AveBedrms',
                                                      'Population', 'AveOccup',
                                                      'Latitude_g',
                                                      'Longitude_g']))])
train_xf_X = preprocessor.transform(train_X)
val_xf_X = preprocessor.transform(val_X)
test_xf_X = preprocessor.transform(test_X)
draw_all_hist(train_xf_X, 'g')

image.png

3. sample_weight計算

transform後のtrain_xf_Xについてマハラノビス距離を求め、そのn乗をsample_weightとする。nを振って精度の変化を確認する作戦。
まず全体について確認してみる。

from sklearn.covariance import MinCovDet

robust_cov = MinCovDet().fit(train_xf_X)
print('Estimated covariance matrix:\n', robust_cov.covariance_)

# mahal_robust_cov = robust_cov.mahalanobis(train_cov_X)
mahal_robust_cov = robust_cov.mahalanobis(train_xf_X)
print(mahal_robust_cov.shape)
print(mahal_robust_cov.max(), mahal_robust_cov.min())

counts, bins = np.histogram(mahal_robust_cov)
plt.hist(bins[:-1], bins, weights=counts)
plt.show()

image.png

上図に現れる少量のマハラノビス距離が離れたsampleを特異としてweightできるか?

4. RandomForestRegressor modelで評価

ここでは無調整のRandomForestRegressorを使用する。sample数とn乗を振りmseで精度比較する。各条件で得る精度はdfに入れる。mseの平均で評価するため各条件にて10回fitさせている。
尚、pow=.0 の時はsample_weightは全部1.になるのでsample_weihgt指定しない時と同じ結果になる(確認済み)。

def predict_with_sample_weight(train_xf_X, train_y, val_xf_X, val_y, pow=0.):
    robust_cov = MinCovDet().fit(train_xf_X)
    mahal_robust_cov = robust_cov.mahalanobis(train_xf_X)

    sample_weight = mahal_robust_cov
    sample_weight = np.power(sample_weight, pow)
    fit_params={'sample_weight': sample_weight}

    mse_val_lst = []
    r2_val_lst = []
    np.random.seed(seed=23)

    for i in range(10):
        reg = RandomForestRegressor()
        reg.fit(train_xf_X, train_y, sample_weight)
        # reg.fit(train_xf_X, train_y, sample_weight)

        y_pred = reg.predict(val_xf_X)
        mse_val_lst.append(mean_squared_error(val_y, y_pred))
        r2_val_lst.append(r2_score(val_y, y_pred))

    return mse_val_lst, r2_val_lst


data_num_lst = [500, 1000, 1500, 2000, 3000]
pow_lst = [0., 0.1, 0.2, 0.3, 0.4, 0.5, 1., 1.5, 2.]
df = pd.DataFrame()

for data_num in data_num_lst:
    for pow in pow_lst:
        mse_val_lst, r2_val_lst = predict_with_sample_weight(train_xf_X[:data_num], train_y[:data_num], val_xf_X, val_y, pow)

        clmn_name = str(data_num) + '_' + str(pow)
        s = pd.Series(mse_val_lst, name=clmn_name)
        df = pd.concat([df, s], axis=1)

mse結果表示用に泥臭いdf処理

df_1 = df.copy()    # df計算に時間がかかったので、念の為copyする
s_mean = df_1.mean(axis = 0)
s_std = df_1.std(axis = 0)

df_mean = pd.DataFrame()
df_std = pd.DataFrame()
for i, data_num in enumerate(data_num_lst):
    s_wk = s_mean[i*9:(i+1)*9].reset_index(drop=True)
    s_wk = s_wk.rename(str(data_num))
    df_mean = pd.concat([df_mean, s_wk], axis=1)

df_mean['pow'] = pow_lst
df_mean = df_mean.set_index('pow')

fig, ax = plt.subplots(figsize=(8, 6), tight_layout=True)
ax.set_ylabel('mse')
df_mean.plot(ax=ax)
ax.grid(axis = 'y', c = 'black', alpha = 0.3, linestyle = '--', linewidth = 1)
plt.show()

image.png

先にも書いた通り、pow=.0はsample_weightが無いときと等しくなる。
どのsample数でも、距離の0.4乗前後でsample_weight無しよりも若干mseが小さくなっていることがわかる。もちろんデータによるので今回はこういう結果であって、違うデータならどうなるかやってみなければわからない。

マハラノビス距離以外でも特異性を各sampleで予め計算できるのであれば、それをsample_weightにすることは可能だろう。
次回は違う非線形modelをoutlierを取り除いたデータでTrainingし(つまりoutlierの存在を知らないmodelを作成)、それにoutlierを含む別データでPredictした結果のmseかmaeを距離に見立ててsample_weightとするケースを実験してみたい。
あと、classificationも。

余談

当初10回fitさせる部分はcross_validate(cv=10)で計算しようとしたんだけれど、fit_paramsに入れたsample_weightの長さがXの長さと一致しないエラーが発生して断念した。Xの数が10で割り切れないと最後の1回分は他の9回と比べて長さが異なって、それにsample_weightが実装上対応できていないような気がした。
時間があるときに原因深堀りしてbugとして報告すること。

sample_weight = np.power(sample_weight, n)
fit_params={'sample_weight': sample_weight}
scores = cross_validate(reg, val_xf_X, val_y, cv=10, 
                        scoring=['neg_mean_squared_error', 'r2'], 
                        fit_params=fit_params)

以上

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1