classification用のsample_weight計算 備忘録

# 概要
classification用のsample_weight計算方法について記事を見つけたので、それを試した備忘録を投稿する。
結果から言うと今回のdatsetでは大きな精度は向上は認められなかった。だけどimbalancedなときは試して見る価値があるし、実装も簡単なので残すこととする。

https://medium.com/gumgum-tech/handling-class-imbalance-by-introducing-sample-weighting-in-the-loss-function-3bdebd8203b4



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

## 1. Dataset
サクっと試せるdatasetが見つからなかったので、scikit learnのmake_classificationでsyntheticなdatasetを作成する。
引数の通りfeaturesは3つ(x1,x2,x3とした)で内2つはinformativeとし共分散を持たせる。class数は3つ(0,1,2)、問題をやや難しくするためhypercubeサイズを小さくしクラスタを近づけている。
もちろんimbalanceにするため、classのsample数比は0.5: 0.45: 0.05と、class'2'を極端に小さくした。

```python
from sklearn.datasets import make_classification

X, y = make_classification(
    n_samples=10000, 
    n_features=3, 
    n_informative=2, 
    n_redundant=1, 
    n_classes=3, 
    n_clusters_per_class=1,
    class_sep=0.8,
    flip_y=0,
    weights=[0.5,0.45,0.05], 
    random_state=42)

df = pd.DataFrame(data=X, columns=['x1','x2','x3'])
df['y'] = y
df_X = df[['x1','x2','x3']]
df_y = df['y']
```

## 2. モデル
3種類、線形モデル1種、treeモデル2種を使ってモデル間でも比較する。

```python
sgd = SGDClassifier(loss='hinge', penalty='l2', max_iter=100)
rf = RandomForestClassifier(n_estimators=200)
xgbr = xgb.XGBClassifier(objective='binary:logistic')

classifiers = {'SGDClassifier': sgd,
              'random_forest': rf, 
              'xgboost': xgbr}
```

## 2. Stratified closs validation
closs validation(cv)するが、imbalanceなので普通にcvするとclass'2'の数foldごとにばらついてしまう。そこでKFold()ではなく、StratifiedKFold()を使用する。
動作確認すると、

```python
# Stratifiedを確認
skf = StratifiedKFold(n_splits=5)

for i, (train_index, test_index) in enumerate(skf.split(df_X, df_y)):
    df_train = df_X.iloc[train_index]
    df_test = df_y.iloc[test_index]
    print(df_test.value_counts())
```

```terminal
0    1000
1     900
2     100
Name: y, dtype: int64
0    1000
1     900
2     100
Name: y, dtype: int64
0    1000
1     900
2     100
Name: y, dtype: int64
0    1000
1     900
2     100
Name: y, dtype: int64
0    1000
1     900
2     100
Name: y, dtype: int64
```

どのfoldも全体のdatsetのclass数比が維持されていることがわかる。

## 3. sample_weightの計算

[Mahalanobis distanceをsample_weightにする 備忘録](https://qiita.com/Ihmon/items/0c9cd30211ea838d776d)でも書いたとおり、sampleごとに稀なclassほど大きなweightを与え、loss計算時に手心を加えてもらおうってもの。

ちなみに、流行り(2023/4現在)のChatGPTに説明してもらうと下記だった。

**scikit-learnでは、fit()関数のsample_weightパラメータを使用して、トレーニングデータの各サンプルに重みを付けます。この目的は、学習プロセスにおいて特定のサンプルをより重要視することであり、データセットが不均衡であるようなシナリオで有用である。
sample_weightを使用すると、学習中にモデルが最適化する損失関数に、各トレーニングサンプルの対応する重みが掛け合わされます。つまり、重みが大きいサンプルほど損失関数への寄与が大きくなり、モデルのパラメータ更新に大きな影響を与えることになります。
例えば、1000個のサンプルがある2値分類問題で、そのうち900個がクラスA、100個がクラスBの場合、データセットはアンバランスな状態です。クラスBのサンプルに高いウェイトを設定することで、学習時にモデルがより注意を払うようにすることができます。sample_weight=[1.0, 9.0]とし、最初の要素がクラスA、2番目の要素がクラスBに対応するようにします。
これは、モデルがクラスBのサンプルにより注意を払うことを意味し、これらのサンプルを正しく分類する能力を向上させるのに役立つと思われます。
各サンプルに割り当てる重みは、現在の問題に基づいて慎重に選択する必要があり、間違った重みを割り当てるとパフォーマンスが低下する可能性があることに注意してください。また、すべてのモデルやアルゴリズムがサンプル重みの使用をサポートしているわけではないので、使用する特定のモデルやアルゴリズムのドキュメントを確認して、サンプル重みをサポートしているかどうかを判断することが重要です。**

sample_weight=[1.0, 9.0]が「900個がクラスA、100個がクラスB」だからのようだが、これはclass_weightのことでsample_weightではない。それっぽい答えなんだけどね。

さらにclass_weightとsample_weightの違いも聞いてみた。

**はい、scikit-learnのLogisticRegressionにおけるsample_weightとclass_weightの違いについて説明します。
sample_weightは、scikit-learnの推定器のfit()関数で使用できるパラメータで、各トレーニングサンプルに重み付けをすることができます。このパラメータの目的は、学習プロセスにおいて特定のサンプルをより重要視することであり、データセットが不均衡である場合や、一部のサンプルが他のサンプルよりも重要である場合に有用である。sample_weightパラメータは、通常、トレーニングサンプルの数に等しい長さの1次元配列となります。
一方、class_weight は scikit-learn の LogisticRegression estimator で使用できるパラメータで、データセット内のクラスの重みを自動的に調整することができます。このパラメータは、あるクラスが他のクラスよりもサンプル数が少ないような、不均衡な分類問題で特に有効です。class_weightパラメータを "balanced "に設定すると、学習データにおけるクラスの頻度に基づいて、自動的に重みを調整することができます。また、辞書や値のリストを設定することで、各クラスの重みを手動で調整することも可能です。
sample_weightとclass_weightの主な違いは、sample_weightは各トレーニングサンプルの重みを個別に調整するために使用し、class_weightはクラスの重みを全体として調整することです。言い換えれば、sample_weightは特定のサンプルをより重要視するために使用でき、class_weightはクラス全体の相対的な重要性を調整するために使用できる。
ロジスティック回帰の文脈では、class_weightは異なるクラスを誤って分類した場合のペナルティを調整するために使用することができます。デフォルトでは、誤分類のペナルティは両方のクラスで同じですが、class_weightを辞書や値のリストに設定することで、各クラスのペナルティを調整することができます。例えば、1000個のサンプルがある2値分類問題で、そのうち900個がクラスA、100個がクラスBの場合、class_weight={0: 1, 1: 9}と設定すると、学習時にクラスBをより重要視することができます。これにより、クラスBを誤分類した場合のペナルティが増加し、学習時にクラスBに注目するようにモデルが働きかけます。**

こちらはオイラの認識と変わりない。

本題に戻り、冒頭のブログによると3種類の計算方法があるらしい。

1. Inverse of Number of Samples (INS)
1. Inverse of Square Root of Number of Samples (ISNS)
1. Effective Number of Samples (ENS)

1,2はほぼ同じ計算で求められるので関数はひとつになりp=1.0がINS、p!=2.0がISNSに相当する。
作者は最後のsample_weightに一律class数（len(u)）を掛けているが、無くても良いように思うのでコメントアウトしている。

```python
def get_sample_weight(arr, p = 1.):
    u, counts = np.unique(arr, return_counts=True)
    # Inverse of Number of Samples (INS)
    sample_weight = 1. / np.power(counts, p)
    # sample_weight = np.power(counts, p)
    sample_weight = sample_weight / np.sum(sample_weight) #* len(u)
    for i, j in zip(u, sample_weight):
        arr = np.where(arr==i, j, arr)

    return arr


def get_effective_sample_weight(arr, beta=0.99):
    u, counts = np.unique(arr, return_counts=True)
    # Effective Number of Samples (ENS)
    effective_num = 1. - np.power(beta, counts)
    sample_weight = (1. - beta) / np.array(effective_num)
    sample_weight = sample_weight / np.sum(sample_weight) #* len(u)
    for i, j in zip(u, sample_weight):
        arr = np.where(arr==i, j, arr)

    return arr
```

## 4. TrainingとValidation
make_predictions()で3種類のモデルについて5回foldしている。各回でtrainingとvalidationを行い結果をDataFrameに追記している。~~また最後のfold結果をmultilabel_confusion_matrix()に渡してconfusion_matrixも出力するようにした。それをcalc_scores()で精度(accuracy, precision, recall)計算している。~~
また、sklearn.metrics.classification_report()でclassごとのprecision, recall, f1 scoreと、precisionのマクロ平均, 重み付き平均を計算する。

```python
def make_predictions(X_train, y_train, classifier, weight_method='nan', param=1.):
    np.random.seed(seed=42)
    results = {}
    mcm_arr = []
    out_dict = {'key':[], 'precision':[], 'recall':[], 'f1-score':[], 'support':[], 'model':[], 'method':[], 'param':[]}

    val_predictions = y_train.copy()
    
    skf = StratifiedKFold(n_splits=5)

    for name, reg in classifier.items():
        reg_name = name
        reg_results = {}
        reg_predictions = np.zeros(len(X))
        print(name)

        for fold, (train_i, val_i) in enumerate(skf.split(df_X, df_y)):
            X_train = df_X.iloc[train_i]
            X_val = df_X.iloc[val_i]
            y_train = np.ravel(df_y.iloc[train_i].values)
            y_val = np.ravel(df_y.iloc[val_i].values)

            if weight_method=='nan':
                reg = reg.fit(X_train, y_train)
            elif weight_method=='INS':
                y_train_sample_weight = get_sample_weight(y_train, param)
                reg = reg.fit(X_train, y_train, sample_weight=y_train_sample_weight)
            elif weight_method=='ENS':
                y_train_sample_weight = get_effective_sample_weight(y_train, param)
                reg = reg.fit(X_train, y_train, sample_weight=y_train_sample_weight)

            prediction_train = reg.predict(X_train)
            prediction_val = reg.predict(X_val)
            reg_predictions[val_i] = prediction_val
            reg_results['train fold '+str(fold)] = accuracy_score(y_train, prediction_train)
            reg_results['val fold '+str(fold)] = accuracy_score(y_val, prediction_val)

        # average result across all folds
        reg_results['validation fold average'] = (reg_results['val fold 0'] + reg_results['val fold 1'] + reg_results['val fold 2'] + reg_results['val fold 3'] + reg_results['val fold 4']) / 5

        results[reg_name] = reg_results
        val_predictions[reg_name] = reg_predictions

        mcm_arr.append(multilabel_confusion_matrix(y_val, prediction_val, labels=[0,1,2]))

        # 最後のfoldで評価指標計算
        wk_dict = classification_report(y_val, prediction_val, output_dict=True)
        key_lst = ['0', '1', '2', 'macro avg', 'weighted avg']
        for k1 in key_lst:
            out_dict['model'].append(reg_name)
            out_dict['method'].append(weight_method)
            out_dict['param'].append(param)
            out_dict['key'].append(k1)
            for k2, v in wk_dict[k1].items():
                out_dict[k2].append(v)

    classification_report_df = pd.DataFrame(out_dict)

    return pd.DataFrame(results), val_predictions, mcm_arr, classification_report_df


# multilabel_confusion_matrix()を元に手計算させたもの。TN, FP, FN, TPの個数が表示される利点はあるが今回使わない。
def calc_scores(classifiers, mcm_arr):
    add_score_arr = []
    for i, name in enumerate(classifiers.keys()):
        reg_name = name
        for j in [0,1,2]:   # classのloop
            wk_lst = mcm_arr[i][j].flatten().tolist()
            TN, FP, FN, TP = wk_lst
            accuracy = (TP + TN)/sum(wk_lst)
            precision = TP/(TP + FP)
            recall = TP/(TP + FN)
            wk_lst = wk_lst + [accuracy, precision, recall, name, j]
            add_score_arr.append(wk_lst)

    wk_df = pd.DataFrame(data=add_score_arr, columns=['TN', 'FP', 'FN', 'TP', 'accuracy', 'precision', 'recall', 'classifier', 'class'])
    return wk_df
```

なお、multilabel_confusion_matrix()の出力は教科書に出てくるconfusion matrixとやや並びが異なる。

```terminal
[array([[[ 997,    3],
         [ 184,  816]],
 
        [[1042,   58],
         [   3,  897]],
 
        [[1769,  131],
         [   5,   95]]]),
 array([[[ 978,   22],
         [  14,  986]],
 
        [[1088,   12],
         [  17,  883]],
 
        [[1896,    4],
         [   7,   93]]]),
 array([[[ 984,   16],
         [  24,  976]],
 
        [[1088,   12],
         [  11,  889]],
 
        [[1886,   14],
         [   7,   93]]])]
```

各2行2列行列は下表の並びとなっている。calc_scores()へ渡してたけど、今回は使わない。

| Act\Pred | else(N) | target(P) |
|:--:|:--:|:--:|
|else(N)| TN  | FP  |
|target(P)| FN  | TP  |

## 5. 精度比較
下記param_lstの通り、make_predictions()へ渡す引数を変化させそれぞれの条件でsample_weightの性能を一度に計る。

```python
param_lst = [['nan', .0], 
             ['INS', .8], ['INS', .9], ['INS', 1.0], ['INS', 1.01], ['INS', 1.02], ['INS', 1.04], ['INS', 1.06], ['INS', 1.08], ['INS', 1.1], ['INS', 1.2], ['INS', 1.4], ['INS', 1.6],
             ['ENS', .9], ['ENS', .99], ['ENS', 0.999], ['ENS', 0.9999]]
all_df = pd.DataFrame(columns=['key', 'precision', 'recall', 'f1-score', 'support', 'model', 'method', 'param'])

for prm in param_lst:
    results, val_predictions, mcm_arr, out_df = make_predictions(df_X, df_y, classifiers, prm[0], prm[1])
    all_df = pd.concat([all_df, out_df], ignore_index=True)
```

imbalancedなclassについて、比較的結果良好なrandom forestのkey='2'を見てみる。

```python
all_df[(all_df['key']=='2') & (all_df['model']=='random_forest')]
```

![Screenshot from 2023-04-23 14-04-28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/8540a9c2-21f2-a3aa-4694-9126a227412a.png)


いくつかのINS（p=1.0以外はISNS）でbaselineよりF1 scoreが良いものが散見される。
imbalancedを考慮したkey='weighted avg'も見てみる。

```python
all_df[(all_df['key']=='weighted avg') & (all_df['model']=='random_forest')]
```

![Screenshot from 2023-04-23 14-08-42.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205257/b3841bd7-25a8-4e3e-f992-db1e05eb2ba7.png)

こちらもbaselineよりもややよい結果となった。ENSは効果がないように見える。

以上