📝

回帰予測の評価指標で0除算に対応する

に公開

はじめに

Polaris.AIで業務委託としてアパレルブランドの需要予測などを行っている@tetsuro731と申します。
ここでは回帰予測のタスクの評価指標を考える上で、しばしば問題となる0除算への対応方法について説明します。

回帰予測とは

いわゆる教師あり機械学習の代表的なものとして、以下の2種類があります。

  • 回帰 (Regression)
    連続値や数量を予測する(例:家の価格、株価、売上、気温など)
  • 分類 (Classification)
    離散的なクラスラベルを予測する(例:スパム/非スパム、購買のある/なしなど)

今回は「回帰タスク」に焦点を当てます。分類と違い、連続的な値を予測することを目的とします。例えば、過去の販売データをもとに、「将来ある店舗でどの商品が何個売れるかを予測する」というタスクが挙げられます。このような需要予測は典型的な回帰タスクの一種です。
回帰予測モデルには、線形回帰などのシンプルなモデルから、決定木をベースとしたもの(RandomForest、LightGBMなど)やニューラルネットワークをベースにしたものまでさまざまです。個人的にはLightGBMをよく使いますが、本記事ではモデル自体よりも「評価指標」にフォーカスします。

回帰における評価指標

回帰予測を行った際、そのモデルがどれだけ良い予測をしているかを数値的に評価する必要があります。そのために使用されるものが「評価指標」です。代表的な評価指標としては、以下のようなものがあります。

MAE (Mean Absolute Error)

  • 平均絶対誤差: 予測値と実測値の差の絶対値の平均を取る。
\text{MAE} = \frac{1}{n}\sum_{i=1}^{n}|y_i - \hat{y}_i|

ここでnは予測するデータの数、y_ii番目のデータの実際の値、\hat{y}_iは予測値です。
MAEは計算が単純で理解しやすいのが特徴です。例えばMAEが5であれば、「だいたい5くらいのズレがあるんだな」と直感的に分かります。一方でデータのスケールが異なる場合に単純に比較することができないという弱点があります。例えば、ドルと円でそれぞれ予測した場合、単位が異なるのでMAEで直接比較できません。

RMSE (Root Mean Squared Error)

  • 平均二乗誤差の平方根: 予測値と実測値の差を二乗し、その平均の平方根を取る
\text{RMSE} = \sqrt{\frac{1}{n}\sum_{i=1}^{n}(y_i - \hat{y}_i)^2}

誤差を二乗するため、良くも悪くも大きな誤差に強くペナルティを課します。外れ値に敏感なので大きな誤差を検知したい場合に適している一方で、少数の外れ値に影響を受けやすいためやや直感的に理解しづらい面もあります。
微分可能など便利な性質があるため、評価指標よりは学習時の損失関数として用いらることが多いです。

MAPE (Mean Absolute Percentage Error)

  • 平均絶対パーセント誤差: 予測値と実測値の差の絶対値を実測値で割り、その平均を取った値。
\text{MAPE} = \frac{1}{n}\sum_{i=1}^{n}\left|\frac{y_i - \hat{y}_i}{y_i}\right|

誤差を割合(%)で示すため、規模が異なるデータ間の比較が容易であり、解釈もしやすいです。そのため回帰の評価指標としてはよく用いられます。
ただし、実測値に0が含まれると分母が0となり計算できなくなるという問題があります

MAPEにおける0除算

さて、ここまででMAE、RMSE、MAPEを紹介しました。
自分が今まで関わったプロジェクトだと、学習の損失関数としてRSME、評価指標としてMAEとMAPEを見ることが多かったです。
ただし、前述の通りMAPEには0除算すると値が発散してしまうという問題点があります。例えば需要予測プロジェクトである店舗における品番ごとの売上数を予測したいとしましょう。
商品数は0を取りうるため、販売数が0の期間があった商品はMAPEを計算することができません。

具体的にコードで考えてみましょう。MAEやMAPEなどはscikit-learnで定義された関数を使うこともできますが、今回は定義がわかりやすいように自前で実装してみましょう。

def mape(y_true, y_pred):
    y_true = np.array(y_true)
    y_pred = np.array(y_pred)
    return np.mean(np.abs((y_true - y_pred) / y_true))

y_true = [3, 0, 2, 7] # 実測値に0を含む
y_pred = [2.5, 0.1, 2, 8]
print("MAPE:", mape(y_true, y_pred))

出力

MAPE: inf

0除算を含むため発散してしまいました。
ではどうすればこれを回避できるでしょうか。いくつか対処法を考えてみます。

対応策1. MAPEの定義をいじる

まず最初に考えられるのは、MAPEの定義に少し手を加えて0除算を回避する方法です。

1-1. 実測値0を無視する

まず最初に考えられるのが、シンプルに実測値0を無視する方法です。

def mape_ignore_zeros(y_true, y_pred):
    y_true = np.array(y_true)
    y_pred = np.array(y_pred)
    mask = (y_true != 0)
    if not np.any(mask):
        return None  # 全部0なら計算できないのでNone等にする
    return np.mean(np.abs((y_true[mask] - y_pred[mask]) / y_true[mask]))
y_true = [3, 0, 2, 7] # 実測値に0を含む
y_pred = [2.5, 0.1, 2, 8]
print("MAPE:", mape_ignore_zeros(y_true, y_pred))

出力

MAPE: 0.10317460317460318

今度は計算できました。MAPE=0.1なので、だいたい10%くらいの誤差があるということになります。
ここで、実測値0を無視しているため、上記のコードの2つ目の要素は無視され、実質

y_true = [3, 2, 7] # 実測値に0を含む
y_pred = [2.5, 2, 8]

の3点でMAPEを計算していることになります。
シンプルでそれらしい値を出すことができますが、実測値0がデータに多く含まれている場合は全体の正確な振る舞いを評価することができないという弱点があります。

1-2. 微少量を足すことで0除算を回避

2つ目の方法は分母に微少量を足すことで0除算を回避するという方法です。

def mape_epsilon(y_true, y_pred, epsilon=1e-6):
    y_true = np.array(y_true)
    y_pred = np.array(y_pred)
    
    # 分母が 0 になるのを防ぐため、(y_true + epsilon) で割る
    percentage_errors = np.abs((y_true - y_pred) / (y_true + epsilon))
    return np.mean(percentage_errors)

y_true = [3, 0, 2, 7]
y_pred = [2.5, 0.1, 2.5, 8]

mape_val = mape_epsilon(y_true, y_pred)
print("MAPE:", mape_val)

出力

MAPE: 25000.139880902145

なんだかすごくMAPEが大きな値になってしまいました。ここでは微少量として10^{-6}で割っているため、0除算を避けることができましたが巨大な値になってしまっています。
ここでの微少量はコントロール可能なパラメータなので、この値を大きくすることで0除算時の影響を抑えることができるもののMAPEの計算値にeplisionによるズレが発生してしまいます。
0を無視する方法と比べると全てのデータを使うことができるという利点があるものの、パラメータの調節など気にしなければいけない部分が多いです。

2. 対処法としての代替指標

さて、これまではMAPEの定義をベースに0除算を避ける方法を考えてきましたが、MAPEと似た別の指標を考えることもできます。
ここではWMAPEとSMAPEを紹介します。

2-1. WMAPE(Weighted MAPE)

  • 重み付き平均絶対パーセント誤差: 各サンプルを単純に平均するMAPEとは違い、実測値の大きさを重みにして全体の誤差率を算出する
\text{WMAPE} = \frac{\sum_{i=1}^{n}|y_i - \hat{y}_i|}{\sum_{i=1}^{n}|y_i|}

一見するとMAPEに似ていますが、分母と分子で別々に和をとっています。y_iが0のサンプルがあったとしても、\sum_{i=1}^{n}|y_i|が0でない限り発散しません。
解釈が分かりづらければ以下のように重みw_iと誤差率に分解することもできます。

\text{WMAPE} = \sum_{i=1}^n \underbrace{\left(\frac{|y_i|}{\sum_{j=1}^n |y_j|}\right)}_{w_i} \times \underbrace{\left(\frac{|y_i - \hat{y}_i|}{|y_i|}\right)}_{\text{誤差率}}

誤差率の全サンプルでの平均がMAPEであることを考えれば、上記の「実測値の大きさを重みにして」の意味が分かると思います。
コードで表すと以下のようになります。


def wmape(y_true, y_pred):
    y_true_arr = np.array(y_true)
    y_pred_arr = np.array(y_pred)
    
    numerator = np.sum(np.abs(y_true_arr - y_pred_arr)) # 分子
    denominator = np.sum(np.abs(y_true_arr))  # 分母

    return numerator / denominator

y_true = [3, 0, 2, 7]  
y_pred = [2.5, 0.1, 2, 8]

val = wmape(y_true, y_pred)
print("WMAPE:", val)

出力

WMAPE: 0.13333333333333333

実測値に0が含まれていても問題なく出力されましたね。
ただし、このコードでは\sum_{i=1}^{n}|y_i|が0でないという仮定を置いていることに注意しましょう。
また、MAPEと違って重み付けがされているため、MAPEほど直感的に理解しづらいという点にも注意が必要です。

2-2. SMAPE(Symmetric MAPE)

  • 対称平均絶対パーセント誤差: MAPEの改良版で、実測値と予測値の絶対値の平均で割る

SMAPEは実測値と予測値の平均で割ることで、実測値に0があっても計算できるようになっています。予測値と実測値の両方とも0だった場合は0除算が発生してしまいますが、予測値の値はクリッピングなどの処理である程度対処可能です。

def smape(y_true, y_pred):
    y_true_arr = np.array(y_true)
    y_pred_arr = np.array(y_pred)
    
    numerator = 2.0 * np.abs(y_true_arr - y_pred_arr)
    denominator = np.abs(y_true_arr) + np.abs(y_pred_arr)
    return np.mean(numerator / denominator)

y_true = [3, 0, 2, 7]  
y_pred = [2.5, 0.1, 2, 8]

val = smape(y_true, y_pred)
print("SMAPE:", val)

出力

SMAPE: 0.5787878787878787

MAPEに近いイメージで使えつつ、実測値が0の場合にも対応可能であるため、個人的には好きな評価指標です。

まとめ

ここでは回帰タスクの評価指標であるMAPEと、実測値に0が存在する場合の対策についてまとめました。
自分は実測値の0があまり重要でない場合はシンプルに「実測値0を無視する」を使うことが多いです。実測値0を扱いたい場合はSMAPEを使うことが多いです。その他の指標も状況によって使い分けます。
いずれにせよ、それぞれの指標の特徴を知っておくことで適切な使い分けにつながるはずです。

Polaris.AIテックブログ

Discussion