盆暗の学習記録

データサイエンス ,エンジニアリング,ビジネスについて日々学んだことの備忘録としていく予定です。初心者であり独学なので内容には誤りが含まれる可能性が大いにあります。

LightGBMにおける欠損値の扱い

Githubでの議論などを見てわかったことをメモしておきます。

前提

欠損値に関するハイパーパラメータがデフォルトのまま(use_missing = truezero_as_missing = false)の場合の挙動について。

学習時の欠損値の取り扱い

numerical featureの欠損値

最も誤差を小さくする枝に割り振る

categorical featureの欠損値

常に右側の枝に割り振る

  • categorical featureの分岐は下の図のようになっていて、その変数の値がある値に該当すれば左に分岐し、それ以外はすべて右に分岐する( if x == 'A' then left, else rightといった感じ)
  • 欠損値は全部の分岐でelseになったパターンになるはず

f:id:nigimitama:20200926175137p:plain

予測時の欠損値の取り扱い

numerical featureの欠損値

  • 学習時に欠損値があった場合: 学習時に割り振った側の枝に割り振って予測する
  • 学習時に欠損値がなかった場合: 欠損値はゼロに変換される1

categorical featureの欠損値

常に右側の枝に分岐していった先で予測が行われる

  • 訓練データに入っていなかったカテゴリはすべて右に分岐する。欠損値も同様。

まとめると

numerical feature categorical feature
学習 最も誤差を下げる枝に割り振る 常に右側の枝に割り振る
予測(欠損既知) 割り振った側の枝で予測する 常に右側の枝で予測
予測(欠損未知) 欠損値をゼロに置換して予測する 常に右側の枝で予測
  • 欠損既知:学習時に当該特徴量が欠損値のレコードがあった
  • 欠損未知:学習時に当該特徴量が欠損値のレコードが無かった

考察

ゼロとして扱われる場合に要注意

numerical featureで予測時にのみ欠損値がある場合は欠損値がゼロとして扱われるため、これの影響がどう出るかはそのデータの分布によって変わりそう。

もしその特徴量においてゼロがめったに出ない値なのであれば、欠損値は別のもっともらしい値で埋めたほうがよさそう。(具体的には、欠損値を埋めるための予測モデルを作って埋めたり、あるいは単にその特徴量の平均値で埋めたり)

各決定木の間の相関が低ければ悪影響は低いのかも?

categorical featureにおける欠損値は「どのカテゴリでもない」という扱いになってしまうが、このときの予測精度はどの程度のものになるのだろう…。

例えば、あるcategorical feature  x が'A', 'B', 'C'の3カテゴリがあるとして、ある木の中では

if x == 'A':
    ...(1)
else:
    if x == 'B':
        ...(2)
    else:
        ...(3)

という感じの分岐になった場合、(3)の領域に含まれる訓練データは'C'であり、 xが'C'のデータに対する誤差(不純度)を最小化するように学習される。

もしこの木で予測をするときに xが欠損値なら、「AでもBでもない」ということで(3)の領域に入り、 xが'C'である場合と同じ扱いを受けるはず。

もし、その予測したいデータの xが実は'C'なのであれば誤差は小さくなるだろうし、それ以外のカテゴリであれば誤差は大きいと考えられる。

単体の決定木で考えると、上のように考えられるが、LightGBMは基本的に多くの木を使用し、feature_fraction(各決定木で使用する特徴量をランダムに選び出した一部の特徴量のみにする)などのハイパーパラメータを使って各決定木の相関が低くなるように成長させていく。

そのため、各決定木間の相関が十分に低ければ、「ある木では欠損値が'A'として扱われ、ある木では'B'として扱われ、ある木では'C'として扱われる…」というのを繰り返していき、最終的には各カテゴリの平均のような値になるのかもしれない。

ただ、仮に全カテゴリの平均に近い値が予測値になるように収束するのだとしても、もし欠損値がある程度予測できるのであればできるかぎり予測して補完したほうが無難な気がする。

FlaskとAjaxを使って非同期にサイトの表示を変える

f:id:nigimitama:20200912141831g:plain

FlaskとjQuerysqliteについて少し勉強したのでメモ。

シンプルな例

まず、シンプルに文字を書き換えるだけの場合について。

f:id:nigimitama:20200912110007g:plain

バックエンド

flaskでサーバー側の処理を書きます。

ページを表示するための index() と、フロントエンドからのAjax通信に対して受け答えするための show() の2つのメソッドを用意します。

# app.py
from flask import Flask, render_template, jsonify, request
import json
app = Flask(__name__)

# トップページにアクセスされたらindex.htmlを表示する
@app.route('/')
def index():
    return render_template("index.html")

# /showにPOSTリクエストが送られたら処理してJSONを返す
@app.route('/show', methods=['POST'])
def show():
    return_json = {
        "message": f"Hello, {request.form['username']}"
    }
    return jsonify(values=json.dumps(return_json))


if __name__ == '__main__':
    app.run(debug=True)

フロントエンド

入力フォームを用意しておいて、入力値に変更があるたびにajaxでサーバーにPOSTリクエストを送るようにしておきます。

<!-- templates/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Example</title>
    <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
</head>
<body>
    <h1 id="midashi">Hello</h1>
    <!-- フォームに値を入れても遷移しないようにするため、onsubmitにfalseを返す -->
    <form name="example_form" onsubmit="return false;">
      <!-- 入力値の変更があったらJSの関数を実行する -->
      <input type="text" name="username" placeholder="Write your name" onchange="changeHandler(this)">
    </form>
    <script>
      function changeHandler(input_value) {
        const username = $(input_value).serialize();
        $.ajax("/show", {
              type: "post",
              data: username,  // POSTでサーバーに送信するデータ
              dataType: "json",
            }).done(function(data) { // 成功した場合実行される
              console.log("Ajax通信 成功");
              
              // POSTリクエストの結果を受け取ってHTMLを書き換える
              const message = JSON.parse(data.values).message
              // document.getElementById("midashi").innerHTML = message; と同義
              $("#midashi").html(message);
            
            }).fail(function(data) { // 失敗した場合実行される
              console.log("Ajax通信 失敗");
        });
      }
    </script>
</body>
</html>

グラフを書き換える

次に、記事冒頭のやつを作る場合について。

f:id:nigimitama:20200912141831g:plain

バックエンド

DBに入っているデータを取り出してグラフを描く感じの想定で、sqlite3を使ってみます

グラフはC3.jsを使い、python側でやる処理は描画のためのデータをフロントに渡すだけです

import sqlite3
from flask import Flask, render_template, jsonify, request, g
import json
app = Flask(__name__)
DATABASE = "./example.db"


@app.route("/")
def index():
    cur = get_db().cursor()
    populations = get_data_as_wide(cur, col="population")
    # C3.js用のデータを作る
    default_value = {
        "bindto": "#chart",
        "data": {
            "rows": populations,
            "x": "year"
        },
        "axis": {
            "y": {}
        }
    }
    return render_template("index.html", default_value=default_value)


@app.route("/show", methods=["POST"])
def show():
    variable = request.form['variable']
    cur = get_db().cursor()
    populations = get_data_as_wide(cur, col=variable)
    new_value = {
        "bindto": "#chart",
        "data": {
            "rows": populations,
            "x": "year"
        },
        "axis": {
            "y": {}
        }
    }
    return jsonify(values=json.dumps(new_value))


def get_db():
    db = getattr(g, '_database', None)
    if db is None:
        db = g._database = sqlite3.connect(DATABASE)
    return db


def get_data_as_wide(cur, col):
    """データを横持ちで取得する"""
    query = f"""
    SELECT
        year,
        max(CASE WHEN prefecture = '東京都' THEN {col} END) AS "東京都",
        max(CASE WHEN prefecture = '神奈川県' THEN {col} END) AS "神奈川県",
        max(CASE WHEN prefecture = '埼玉県' THEN {col} END) AS "埼玉県"
    FROM populations
    GROUP BY year
    """
    rows = cur.execute(query).fetchall()
    header = ["year", "東京都", "神奈川県", "埼玉県"]
    populations = [header] + [list(row_tuple) for row_tuple in rows]
    return populations


def setup_database():
    """データベースを作っておく"""
    # ※.dbファイルが無くても作られるので問題ない
    con = sqlite3.connect(DATABASE)
    cur = con.cursor()
    cur.execute("DROP TABLE IF EXISTS populations")
    cur.execute("""
    CREATE TABLE populations
    (prefecture text, year integer, population integer, population_male integer, population_female integer)
    """)
    # 国勢調査の人口データ
    populations = [
        ('埼玉県', 1995,  6759311, 3419218, 3340093),
        ('東京都', 1995, 11773605, 5892704, 5880901),
        ('神奈川県', 1995,  8245900, 4209525, 4036375),
        ('埼玉県', 2000,  6938006, 3500224, 3437782),
        ('東京都', 2000, 12064101, 6028562, 6035539),
        ('神奈川県', 2000,  8489974, 4308786, 4181188),
        ('埼玉県', 2005,  7054243, 3554843, 3499400),
        ('東京都', 2005, 12576601, 6264895, 6311706),
        ('神奈川県', 2005,  8791597, 4444555, 4347042),
        ('埼玉県', 2010,  7194556, 3608711, 3585845),
        ('東京都', 2010, 13159388, 6512110, 6647278),
        ('神奈川県', 2010,  9048331, 4544545, 4503786),
        ('埼玉県', 2015,  7266534, 3628418, 3638116),
        ('東京都', 2015, 13515271, 6666690, 6848581),
        ('神奈川県', 2015,  9126214, 4558978, 4567236)
    ]
    cur.executemany('INSERT INTO populations VALUES (?,?,?,?,?)', populations)
    con.commit()
    con.close()


if __name__ == "__main__":
    setup_database()
    app.run(debug=True)

sqliteにもWebアプリ制作にも慣れていないので非常に非効率でコードが長くなることをやってしまっているような気がします…

フロントエンド

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Example</title>
  <script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.16.0/d3.min.js" integrity="sha512-FHsFVKQ/T1KWJDGSbrUhTJyS1ph3eRrxI228ND0EGaEp6v4a/vGwPWd3Dtd/+9cI7ccofZvl/wulICEurHN1pg==" crossorigin="anonymous"></script>
  <script src="https://cdnjs.cloudflare.com/ajax/libs/c3/0.7.20/c3.min.js" integrity="sha512-+IpCthlNahOuERYUSnKFjzjdKXIbJ/7Dd6xvUp+7bEw0Jp2dg6tluyxLs+zq9BMzZgrLv8886T4cBSqnKiVgUw==" crossorigin="anonymous"></script>
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/c3/0.7.20/c3.css" integrity="sha512-GQSxWe9Cj4o4EduO7zO9HjULmD4olIjiQqZ7VJuwBxZlkWaUFGCxRkn39jYnD2xZBtEilm0m4WBG7YEmQuMs5Q==" crossorigin="anonymous" />
</head>
<body>
  <form onsubmit="return false;">
    <select name="variable" onchange="changeHandler(this)">
      <option value="population">人口(男女計)</option>
      <option value="population_male">人口(男性)</option>
      <option value="population_female">人口(女性)</option>
    </select>
  </form>

  <div id="chart"></div>

  <script>
    let data = {{ default_value | safe }};
    data = add_tooltip(data);
    let chart= c3.generate(data);

    function add_tooltip(data_for_c3) {
      // python側からd3.formatを書く方法がわからなかったので暫定的にこっちで処理
      data_for_c3['tooltip'] = {format: {value: function(value){return d3.format(",.0f")(value)}}};
      return data_for_c3;
    }

    function changeHandler(input_value) {
      const value = $(input_value).serialize();
      $.ajax("/show", {
            type: "post",
            data: value,  // POSTでサーバーに送信するデータ
            dataType: "json",
          }).done(function(response) { // 成功した場合実行される
            console.log("Ajax通信 成功");
            // POSTリクエストの結果を受け取ってHTMLを書き換える
            let new_value = JSON.parse(response.values);
            new_value = add_tooltip(new_value);
            chart= c3.generate(new_value);
          }).fail(function(data) { // 失敗した場合実行される
            console.log("Ajax通信 失敗");
      });
    }
  </script>
</body>
</html>

こういうものを作るのに慣れてくればダッシュボードを自前で作ったりできそうですね

細部をカスタマイズしたいとかの要件がなければ、簡単にダッシュボードを作れるツールやライブラリを使ったほうが早いでしょうけど…

なぜバギングは予測誤差を減らすのか

機械学習の分野では、バギング(bagging)というアンサンブル学習の方法があります。

なぜバギングは予測誤差を下げるのか?という点について少し学んだのでメモしておきます。

※記事全文はGithubに載せています

2日ほどはてなブログでの数式の記述(はてなTeX記法やMathJaxの手動導入など)と格闘しましたが、ある程度複雑な数式になるとどうしても表示が崩れてしまうので、諦めてgithub pagesに載せました

はてなブログには要点だけ載せます。 本来の記事全文は以下で御覧ください。

nigimitama.github.io

バギングによるバリアンスの低減

解析のため、非現実的な仮定を置きます。真の分布\(\mathcal P\)から無限に学習データセットが得られるとし、その下で構成される理想的な平均化推定量(aggregated estimator)\(\bar{f}(x) = \text{E}_{\mathcal P} [\hat{f}(x)]\)があったとします。

ここで\(\hat{f}(x)\)は分布\(\mathcal{P}\)から独立に取り出した学習データ\(\{x_i, y_i\}_{i=1}^N\)で学習した予測器の、入力点\(x\)における予測値です。

固定された入力点\(x\)の下での平均2乗誤差(MSE)を分解すると \[ \begin{align}\text{E}_{\mathcal P} [(Y - \hat{f}(x))^2]&= \text{E}_{\mathcal P} [(Y - \bar{f}(x) + \bar{f}(x) -\hat{f}(x))^2]\\ &= \text{E}_{\mathcal P} [(Y - \bar{f}(x))^2] + \underbrace{\text{E}_{\mathcal P} [(\hat{f}(x) - \bar{f}(x))^2]}_{Variance}\\ &\geq \text{E}_{\mathcal P} [(Y - \bar{f}(x))^2]\end{align} \] 2行目の第2項\(\text{E}_{\mathcal P} [(\hat{f}(x) - \bar{f}(x))^2]\)は平均\(\bar{f}(x)\)のまわりでの\(\hat{f}(x)\)の分散(バリアンス)です。

上の式は、\(Y\)と平均化推定量\(\bar{f}(x)\)の平均2乗誤差\(\text{E}_{\mathcal P} [(Y - \bar{f}(x))^2]\)は、\(Y\)と推定量\(\hat{f}(x)\)の平均2乗誤差\(\text{E}_{\mathcal P} [(Y - \hat{f}(x))^2]\)よりもバリアンスの分だけ小さいことを示しています。

一般的に、バイアスとバリアンスの間にはトレードオフ関係があり、バリアンスを下げるとバイアスが増える傾向がありますが、バギングを使うことで、バイアスを増やさずにバリアンスをゼロにすることができるわけです。

上の議論は真の分布\(\mathcal P\)から無限にデータセットが得られる場合で、現実にはありえません。Breiman (1996)が提案したバギングでは、この無限のデータセットをブートストラップサンプルで近似したものになります。ブートストラップによる現実のバギングでも、多くの場合はこのようにバリアンスを下げてくれます。

ただし、回帰においてバギングは平均2乗誤差を増加させず減少させますが、分類の場合は多数決になるので、一定以上の精度がないと逆に悪化させる可能性があります。

バギングの効果

Breiman(1999)が行った実験結果をまとめたのが以下の表です。

f:id:nigimitama:20200428230801p:plain

Data Setはデータセット名、Noiseはデータセットのノイズです。

Unpruned CARTは、枝刈りを行わなかった単体の決定木で、Baggingは枝刈りしていない50本の決定木で構成されたBaggingです。

理論解析の結論とほぼ合致していて、Baggingが単体の決定木と同程度のBiasを保ったままVarianceを大幅に削減していることがわかります。

理論解析と違ってVarianceがゼロにならないのは、

  1. データセットがブートストラップサンプルによる近似であること
  2. 決定木同士の相関がある

という理由が考えられます。

そのため、ランダムフォレスト(Breiman 2001)では、複数の決定木を作っていくフェーズで、あらかじめ決めた数の特徴量をランダムに選び出して決定木を作ることで決定木間の相関を大幅に減らすように工夫されています。

参考

Breiman, L. (1996). Bagging predictors. Machine learning, 24(2), 123-140.

Breiman, L. (1999). Using adaptive bagging to debias regressions (p. 16). Technical Report 547, Statistics Dept. UCB.

Breiman, L. (2001). Random forests. Machine learning, 45(1), 5-32.

Hastie, T., Tibshirani, R., & Friedman, J. (2009). The elements of statistical learning: data mining, inference, and prediction. Springer Science & Business Media.

予測誤差のバイアス-バリアンス分解の導出

予測誤差や推定量の推定の誤差は、バイアス(bias)とバリアンス(variance)という2つの構成要因に分けることができます。分けることで、誤差を削減する方法について議論しやすくなります。

定量のバイアスーバリアンス分解については以前の記事で書いたことがあります

nigimitama.hatenablog.jp

が、今回は目的変数を$Y=f(X) + \varepsilon$として、予測可能な部分$f(X)$と予測不能な部分$\varepsilon$があるものに対する予測誤差の分解について、どういう式の展開があった結果そうなったのかについてメモしておきます。

※一部の数式でレンダリングが失敗して、解決できなかったので画像で入れています。
Github Pagesに元のノートを公開しましたので、見づらい場合はこちらを御覧ください。
https://nigimitama.github.io/note/20200426_bias_variance_decomposition.html

概要

前提

特徴量ベクトルを$X$とし、目的変数は$Y=f(X) + \varepsilon$であると仮定します。

$f(X)$は$Y$の予測可能な部分で、$\varepsilon$は予測不能なノイズで、

f:id:nigimitama:20200428073143p:plain

とします。

議論を簡潔にするため、データにおける$x_i$の値は決定論的に決められているとします。

学習データセット$\mathcal{D}$で学習した予測器を$\hat{f}(x; \mathcal{D})$とします。

様々な学習データセット$\mathcal{D}$にわたっての期待値をとる操作を${\rm E}_{\mathcal{D}}$と表すことにします。

平均2乗誤差の分解

平均2乗誤差の下での、$X=x_0$における期待予測誤差(expected prediction error)もしくは汎化誤差(generalization error)と呼ばれるものは、

\[ {\rm EPE} (x_0) = {\rm E}_{\mathcal{D}}[(Y - \hat{f}(x_0; \mathcal{D}))^{2} | X = x_0] \]

であり、これを分解すると

f:id:nigimitama:20200428073226p:plain

と分解できることが知られています。

展開

この分解の過程を以下で整理していきます。ただし、記号を以下のように簡略化します。

\[ \begin{align}f &:= f(x_0)\\\hat{f} &:= \hat{f}(x_0; \mathcal{D})\\{\rm E} &:= {\rm E}_{\mathcal{D}}\\{\rm Var} &:= {\rm Var}_{\mathcal{D}}\\\end{align} \]

\[ \begin{align}{\rm EPE}(x_0)&={\rm E}[(Y-\hat{f})^{2}]\\&={\rm E}[(Y - {\rm E}[\hat{f}] + {\rm E}[\hat{f}] - \hat{f})^{2}]\\&={\rm E}[(Y - {\rm E}[\hat{f}])^{2} + 2(Y - {\rm E}[\hat{f}])({\rm E}[\hat{f}] - \hat{f}) + ({\rm E}[\hat{f}] - \hat{f})^{2}]\\&={\rm E}[(Y - {\rm E}[\hat{f}])^{2}]+ {\rm E}[2(Y - {\rm E}[\hat{f}])({\rm E}[\hat{f}] - \hat{f})]+ {\rm E}[({\rm E}[\hat{f}] - \hat{f})^{2}] \tag{1}\end{align} \]

ここで式$(1)$の第1項は

\[ \begin{align}&{\rm E}[(Y - {\rm E}[\hat{f}])^{2}]\\&= {\rm E}[Y^{2} - 2Y{\rm E}[\hat{f}] + {\rm E}[\hat{f}]^{2}]\\&= {\rm E}[(f+\varepsilon)^{2} - 2(f + \varepsilon){\rm E}[\hat{f}]+ {\rm E}[\hat{f}]^{2}]\\&={\rm E}[(f^{2} + 2f\varepsilon + \varepsilon^{2})- (2f{\rm E}[\hat{f}] + 2\varepsilon{\rm E}[\hat{f}])- {\rm E}[\hat{f}]^{2}]\tag{2}\end{align} \]

と分解でき、ここで${\rm E}[\varepsilon]=0$と期待値の線形性$c{\rm E}[X] = {\rm E}[cX]$から、$\varepsilon$が含まれる項はゼロが掛かってゼロになるので式$(2)$は

\[ \begin{align}&{\rm E}[(f^{2} + 2f\varepsilon + \varepsilon^{2})- (2f{\rm E}[\hat{f}] + 2\varepsilon{\rm E}[\hat{f}])+ {\rm E}[\hat{f}]^{2}]\tag{2}\\&= {\rm E}[\varepsilon^{2}]+ {\rm E}[f^{2} - 2f{\rm E}[\hat{f}] +{\rm E}[\hat{f}]^{2}]\\&= {\rm E}[\varepsilon^{2}]+ {\rm E}[(f - {\rm E}[\hat{f}])^{2}]\end{align} \]

です。

式$(1)$の第2項は、${\rm E}[\hat{f}]$が定数であることと、期待値と定数の関係${\rm E}[X - c] = {\rm E}[X]-c$から、

\[ {\rm E}[{\rm E}[\hat{f}] - \hat{f}]= {\rm E}[\hat{f}] - {\rm E}[\hat{f}] = 0 \]

なので、第2項はゼロになります。

ゆえに式$(1)$は

\[ \begin{align}&{\rm E}[(Y - {\rm E}[\hat{f}])^{2}]+ {\rm E}[2(Y - {\rm E}[\hat{f}])({\rm E}[\hat{f}] - \hat{f})]+ {\rm E}[({\rm E}[\hat{f}] - \hat{f})^{2}] \tag{1}\\&= {\rm E}[\varepsilon^{2}] + {\rm E}[(f - {\rm E}[\hat{f}])^{2}]+ {\rm E}[({\rm E}[\hat{f}] - \hat{f})^{2}]\tag{3}\end{align} \]

となります。

式$(3)$の第1項は、

\[ \begin{align}{\rm Var}[\varepsilon]&= {\rm E}[(\varepsilon - {\rm E}[\varepsilon])^{2}]\\&= {\rm E}[\varepsilon^{2} -2\varepsilon {\rm E}[\varepsilon] + {\rm E}[\varepsilon]^{2}]\\&= {\rm E}[\varepsilon^{2} -2\varepsilon \cdot 0 + 0^{2}]\\&= {\rm E}[\varepsilon^{2}]\end{align} \]

なので、ノイズ$\varepsilon$の分散に等しいことになります。

式$(3)$の第2項は、$f$と${\rm E}[\hat{f}]$が定数であるため

\[ \begin{align}&{\rm E}[(f - {\rm E}[\hat{f}])^{2}]\\&=(f - {\rm E}[\hat{f}])^{2}\\\end{align} \]

で、これは真の関数$f$と予測値の期待値${\rm E}[\hat{f}]$の差(バイアス)の2乗です。

式$(3)$の第3項は、予測値$\hat{f}$の分散(バリアンス)です。

よって、 f:id:nigimitama:20200428073736p:plain

です。

最初からの展開をまとめると

f:id:nigimitama:20200428073718p:plain

となります。

各項の意味

削減不能な誤差(irreducible error)

f:id:nigimitama:20200428073817p:plain

$Y$の分散であり、ノイズの分散。

データの測定誤差などに由来します。

削減できないので、予測誤差のバイアスーバリアンス分解を議論するときに、この項を省くために、$Y$と$\hat{f}$の誤差ではなく$f$と$\hat{f}$の誤差を分解して

\[ \begin{align}&{\rm E}[(f - \hat{f})^{2}]\\&= {\rm E}[(f - {\rm E}[\hat{f}] + {\rm E}[\hat{f}] - \hat{f})^{2}]\\&= {\rm E}[(f - {\rm E}[\hat{f}])^{2}]+ 2(f - {\rm E}[\hat{f}])\underbrace{{\rm E}[({\rm E}[\hat{f}] - \hat{f})]}_{=0}+ {\rm E}[({\rm E}[\hat{f}] - \hat{f})^{2}]\\&= (f - {\rm E}[\hat{f}])^{2}+ {\rm E}[({\rm E}[\hat{f}] - \hat{f})^{2}]\\&= {\rm Bias}(\hat{f})^{2} + {\rm Var}(\hat{f})\end{align} \]

とする場合もあります。

バイアス(Bias)

\[ {\rm Bias}(\hat{f}(x_0; \mathcal{D}))= {\rm E}_{\mathcal{D}}[\hat{f}(x_0; \mathcal{D})]- f(x_0) \]

予測値の平均と真の値との差。

多くの場合、より複雑なモデルを用いて予測すると、それだけバイアスは減少し、代わりにバリアンスが増加します。

バリアンス(Variance)

\[ {\rm Var}_{\mathcal{D}}(\hat{f}(x_0; \mathcal{D}))= {\rm E}_{\mathcal{D}}[(\hat{f}(x_0; \mathcal{D}) - {\rm E}_{\mathcal{D}}[\hat{f}(x_0; \mathcal{D})])^{2}] \]

(学習データセットを変えていったときの)予測値の分散。

学習データセットを変えるたびに予測値がばらつく、ということは、学習・予測の安定性が低いということ。

すなわち、単一の学習データセット過学習していることに由来すると考えられます。

参考文献

バイアス-バリアンス - 機械学習の「朱鷺の杜Wiki」

偏りと分散 - Wikipedia

Hastie, T., Tibshirani, R., & Friedman, J. (2009). The elements of statistical learning: data mining, inference, and prediction. Springer Science & Business Media.

はじめてのパターン認識

はじめてのパターン認識

  • 作者:平井 有三
  • 発売日: 2012/07/31
  • メディア: 単行本(ソフトカバー)

機械学習モデルを動かすWeb APIを作ってみる(5):Lambdaにデプロイ

これまで,APIの作成からHerokuへのデプロイまでを扱ってきました。 今回はAWS Lambda + API Gatewayでのデプロイについてメモしておきます。

前回はchaliceというアプリの作成&Lambda + API Gatewayへのデプロイを楽にできるライブラリを扱いました。

しかし,以下に述べるLambdaをそのまま弄る方法のほうが正直chaliceより楽かなという印象です。

Lambda関数を作成

AWS コンソールから作成します。

LambdaのLayerにパッケージを追加する

ソースコードと一緒に直接アップロードできるのは50MBまでなので,NumpyやらScikit-learnやらを使っている今回のコードはその制約をオーバーしてしまいます。

しかし,Layerとして別口でアップロードしておき,Lambda関数から読み込ませるようにする場合は250MBまで読み込めます。以下ではLayerを作っていきます。(ここは前回と同じです)

Amazon Linux2 OSのEC2インスタンスを立ち上げ,sshで接続する

pip installしてzipにする

# python3のインストール
sudo yum update -y
sudo yum install python3-devel python3-libs python3-setuptools -y
sudo python3 -m pip install --upgrade pip

# numpyのzip化 -------------
mkdir python
pip3 install -t ./python/ numpy
zip -r numpy.zip python
rm -r python

# scipyのzip化 -------------
mkdir python
pip3 install -t ./python/ scipy
# numpyは別Layerにするので消す
rm -r ./python/numpy*
zip -r scipy.zip python
rm -r python

# pandasのzip化 ------------
mkdir python
pip3 install -t ./python/ pandas==0.25.3
# numpyは別Layerにするので消す
rm -r ./python/numpy*
zip -r pandas.zip python
rm -r python

# sklearnのzip化 -----------
mkdir python
pip3 install -t ./python/ sklearn
# numpy, scipyは別Layerにするので消す
rm -r ./python/numpy* ./python/scipy*
zip -r sklearn.zip python
rm -r python

# lightgbmのzip化 ----------
mkdir python
pip3 install -t ./python/ --no-deps lightgbm
zip -r lightgbm.zip python
rm -r python

zipをダウンロード

exit

でEC2を抜けたら

scp -i ~/.ssh/[pemファイル名].pem ec2-user@[IPv4]:~/*.zip .

のような感じでローカルにダウンロードします。

zipをアップロード

  • AWSコンソール→Lambda→Layer→「レイヤーの作成」からzipをアップロードします

    • レイヤー名はpythonコードでimportするときの名前にします

Layerの追加

AWSコンソールのLambda関数の画面から,作成中のAPIの関数に先程作成したLayerを追加します。

(マージ順序は気にしなくて大丈夫です)

f:id:nigimitama:20200307234542p:plain

APIのコードを修正

Lambda用に修正してアップロードします。

コードの書き換え

前回までのAPIのコードを,こんな感じに書き換えます。

import pandas as pd
import json
import pickle
from datetime import datetime
import sys
sys.path.append("./modules")  # 前処理で使った自作モジュール「pipeline」を読み込むためPYTHONPATHに追加

# アプリ起動時に前処理パイプラインと予測モデルを読み込んでおく
preprocess = pickle.load(open("modules/preprocess.pkl", "rb"))
model = pickle.load(open("modules/model.pkl", "rb"))


def predict(event, context):
    """リクエストされたら予測値を返す関数"""
    try:
        # リクエストのbodyをjsonからdictに変換(API Gatewayのリクエスト形式に対応)
        data = json.loads(event['body'])
        # APIにJSON形式で送信された特徴量
        X = pd.DataFrame(data, index=[0])
        # 特徴量を追加
        X["trade_date"] = datetime.now()
        # 前処理
        X = preprocess.transform(X)
        # 予測
        y_pred = model.predict(X, num_iteration=model.best_iteration_)
        response = {"status": "OK", "predicted": y_pred[0]}
        # レスポンスもbodyにjsonを入れる(API Gatewayの仕様に対応)
        return {
            "body": json.dumps(response),
            "statusCode": 200
        }
    except Exception:
        response = {"status": "Error", "message": "Invalid Parameters"}
        return {
            "body": json.dumps(response),
            "statusCode": 400
        }

このようなreturnにする理由は,API Gatewayにレスポンスを指示するためです。

(参考:API ゲートウェイでの「不正な Lambda プロキシ応答」または 502 エラーの解決

zipファイルにしてアップロード

予測モデルの.pklファイルなどとともにzipにしてAWSコンソールからアップロードし,「保存」を押します。

Lambdaの設定

コードエディタのような画面がでてきたら「ハンドラ」部分をファイル名.関数名 の形にします。

f:id:nigimitama:20200307234650p:plain

また,その下に「基本設定」という部分があるので,メモリを引き上げておきます。

今回の例では200MB程度あれば十分のはずです。

f:id:nigimitama:20200307234711p:plain

API Gatewayの設定

REST APIにします

f:id:nigimitama:20200307234822p:plain

動作確認

AWSコンソール上でテスト

AWSコンソール上部でテストイベントを作成し,以下の値を入力します。

{
    "address": "東京都千代田区",
    "area": 30,
    "building_year": 2013
}

テストイベントを保存したら「テスト」のボタンを押してテストします。

以下のような画面になれば成功です。 f:id:nigimitama:20200307234854p:plain

ローカルからテスト

AWSコンソール上のAPI Gatewayの部分にAPIのエンドポイント(URL)とAPIキーがあるので,そこへPOSTリクエストを送ります。

import unittest
import requests
import json


class APITest(unittest.TestCase):
    URL = "APIエンドポイント"
    HEADERS = {"x-api-key": "APIキー"}
    DATA = {
        "address": "東京都千代田区",
        "area": 30,
        "building_year": 2013
    }

    def test_normal_input(self):
        # リクエストを投げる
        response = requests.post(self.URL, json=self.DATA, headers=HEADERS)
        # 結果
        print(response.text)  # 本来は不要だが,確認用
        result = json.loads(response.text)  # JSONをdictに変換
        # ステータスコードが200かどうか
        self.assertEqual(response.status_code, 200)
        # statusはOKかどうか
        self.assertEqual(result["status"], "OK")
        # 非負の予測値があるかどうか
        self.assertTrue(0 <= result["predicted"])


if __name__ == "__main__":
    unittest.main()

実行したところ,ちゃんと動きました

{"status": "OK", "predicted": 45833222.1903707}
.
----------------------------------------------------------------------
Ran 1 test in 0.398s

OK

もちろんcurlコマンドでも検証できます。

curl https://xxxxxxxxxxxxxxxx.execute-api.ap-northeast-1.amazonaws.com/dev/test \
-d '{"address": "東京都千代田区", "area": 30, "building_year": 2013}' \
-H 'x-api-key: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' \
-v
{"status": "OK", "predicted": 45833222.1903707}

ちゃんと機械学習モデルによる予測値が返ってくるのが確認できました。

今回はここまで。

今回使用したコード

Githubにおいてありますのでご関心のある方はご覧ください。

github.com

機械学習モデルを動かすWeb APIを作ってみる(4):chaliceでLambdaにデプロイ

これまで,APIの作成からHerokuへのデプロイまでを扱ってきました。 今回はAWS Lambda + API Gatewayでのデプロイについてメモしておきます。


f:id:nigimitama:20200223205516p:plain

今回はchaliceを使ってAWS Lambda + API Gatewayでデプロイしていきます。

chaliceAWS謹製のライブラリで,Flaskライクな文法でWebアプリを作ることができ,コマンド一つでLambda + API Gatewayの構成でデプロイすることができます。

chaliceのセットアップ

インストール

pip3 install chalice

プロジェクトの作成

chalice new-project <project_name>

プロジェクトを作成すると,app.pyrequirements.txtの入ったディレクトリが作られるので,そちらに移動してapp.pyを書き換えていきます。

app.pyの書き換え

最初はこんな感じになってます。

from chalice import Chalice

app = Chalice(app_name='project_name')

@app.route('/')
def index():
    return {'hello': 'world'}

これを以下のように書き換えます。

from chalice import Chalice, Response
import pandas as pd
import pickle
from datetime import datetime
import sys
import json
sys.path.append("./modules")  # 前処理で使った自作モジュール「pipeline」を読み込むためPYTHONPATHに追加
app = Chalice(app_name='with_chalice')


# アプリ起動時に前処理パイプラインと予測モデルを読み込んでおく
preprocess = pickle.load(open("modules/preprocess.pkl", "rb"))
model = pickle.load(open("modules/model.pkl", "rb"))


@app.route('/predict', methods=["POST"])
def predict():
    """/predict にPOSTリクエストされたら予測値を返す関数"""
    try:
        # APIにJSON形式で送信された特徴量
        request = app.current_request
        X = pd.DataFrame(request.json_body, index=[0])
        # 特徴量を追加
        X["trade_date"] = datetime.now()
        # 前処理
        X = preprocess.transform(X)
        # 予測
        y_pred = model.predict(X, num_iteration=model.best_iteration_)
        response = {"status": "OK", "predicted": y_pred[0]}
        return Response(body=json.dumps(response),
                        headers={'Content-Type': 'application/json'},
                        status_code=200)
    except Exception as e:
        print(e)  # デバッグ用
        response = {"status": "Error", "message": "Invalid Parameters"}
        return Response(body=json.dumps(response),
                        headers={'Content-Type': 'application/json'},
                        status_code=400)

requirements.txtは空のままで

機械学習モデル部分で使用しているライブラリを載せるんですが,Lambda関数に含めることができるのは50MBまでなので,LightGBM(とその依存ライブラリ)だけで超過します。

なのでライブラリは後述するLayerという仕組みを使って別のルートからアップロードすることにし,requirements.txtはそのままにします。

ローカルでテスト

chalice local

localhost:8000にサーバーが起動するのでPOSTリクエストを投げてテストします。

(存在しないURLにリクエストを投げたときにNot Foundじゃなく{"message":"Missing Authentication Token"}が返ってくるというややミスリーディングな仕様なのでご注意を)

$ python3 api_test.py 
{"status": "OK", "predicted": 45833222.1903707}
.
----------------------------------------------------------------------
Ran 1 test in 0.064s

OK

ローカルで問題なく動くようであればデプロイしていきます。

AWS Credentials

deployの前にAWSの資格情報を編集します。

アクセスキーはAWSコンソールのセキュリティ資格情報のところで作成します。

$mkdir ~/.aws
$ cat >> ~/.aws/config
[default]
aws_access_key_id=YOUR_ACCESS_KEY_HERE
aws_secret_access_key=YOUR_SECRET_ACCESS_KEY
region=YOUR_REGION (such as us-west-2, us-west-1, etc)

LambdaのLayerにパッケージを追加する

LightGBM, scikit-learnなどの外部ライブラリをrequirements.txtに記述してchaliceのデプロイ時にアップロードさせる方法(直接アップロード)では,圧縮済みファイルで50MBが上限となります。

Numpy, Scipy, Pandas, LightGBM, Scikit-learnなどを使っている今回のプロジェクトは50MB以下に収まらないので,直接アップロードはできません。

しかし,ここを見ると,直接アップロードなら50MBが上限でも,Layerから読み込む方法なら解凍時で250MBまでは使えるようです。

なので,lambda関数にまとめるのではなく,Layerとして別口でアップロードしておき,Lambda関数から読み込ませるようにします。

以下の記事を参考にしてLayerを作っていきます。

Amazon Linux2 OSのEC2インスタンスを立ち上げ,sshで接続します

pip installしてzipにする

# python3のインストール
sudo yum update -y
sudo yum install python3-devel python3-libs python3-setuptools -y
sudo python3 -m pip install --upgrade pip

# numpyのzip化 -------------
mkdir python
pip3 install -t ./python/ numpy
zip -r numpy.zip python
rm -r python

# scipyのzip化 -------------
mkdir python
pip3 install -t ./python/ scipy
# numpyは別Layerにするので消す
rm -r ./python/numpy*
zip -r scipy.zip python
rm -r python

# pandasのzip化 ------------
mkdir python
pip3 install -t ./python/ pandas==0.25.3
# numpyは別Layerにするので消す
rm -r ./python/numpy*
zip -r pandas.zip python
rm -r python

# sklearnのzip化 -----------
mkdir python
pip3 install -t ./python/ sklearn
# numpy, scipyは別Layerにするので消す
rm -r ./python/numpy* ./python/scipy*
zip -r sklearn.zip python
rm -r python

# lightgbmのzip化 ----------
mkdir python
pip3 install -t ./python/ --no-deps lightgbm
zip -r lightgbm.zip python
rm -r python

zipをダウンロード

exit

でEC2を抜けたら

scp -i ~/.ssh/[pemファイル名].pem ec2-user@[IPv4]:~/*.zip .

のような感じでローカルにダウンロードします。

zipをアップロード

  • AWSコンソール→Lambda→Layer→「レイヤーの作成」からzipをアップロードします

    • レイヤー名はpythonコードでimportするときの名前にします

Layerの追加

AWSコンソールのLambda関数の画面から,作成中のAPIの関数に先程作成したLayerを追加します。

ソースコード以外のデプロイしたいファイルはvendorに入れる

chaliceでdeployしたいファイルが有る場合はvendorというディレクトリを作って入れる必要があります(Chalice documentation)。

今回は予測モデルのpklファイルを入れていたmodulesというディレクトリをvendorに入れます

mkdir vendor
cp -r modules/ vendor/

デプロイする

ようやくデプロイです!

chalice deploy

動作確認

LambdaのURLにPOSTリクエストを投げて確認します。

$ python3 api_test.py
{"status": "OK", "predicted": 45833222.1903707}
.
----------------------------------------------------------------------
Ran 1 test in 3.051s

OK

ちゃんと動作しているようです。

Githubリポジトリ

今回のファイル構成などはこちらになります。

github.com

機械学習モデルを動かすWeb APIを作ってみる(3):Herokuにデプロイ

前回,前々回とでAPI部分をつくってきました。

機械学習モデルを動かすWeb APIを作ってみる(1):APIの作成 - 盆暗の学習記録

機械学習モデルを動かすWeb APIを作ってみる(2):uWSGIの設定 - 盆暗の学習記録

今回からはAPIのデプロイ(Web上への展開)についてメモしていきます。

f:id:nigimitama:20200215102827j:plain

PaaS(Platform as a Service)を使うと簡単にデプロイできるらしいので,今回はHerokuを使ってみます。

Herokuの準備

  1. Herokuの会員登録を行います。
  2. Heroku Dev CenterでHeroku CLIをダウンロードします。
  3. CLIでログインしておきます。
heroku login

uWSGIを設定

ドキュメントを参考に,herokuに向けた設定を行います。

uWSGIの設定ファイルを以下のように変更します。

[uwsgi]
# WSGI moduleをロード(アプリの.pyファイル名)
module = api

# set default WSGI callable name
callable = app

# スレッドごとにアプリ全体を読み込む(メモリ消費は増えるが,これを設定しないとLGBMが動かない)
lazy-apps = true

# heroku用のポートを使う
http-socket = :$(PORT)

# SIGTERMとSIGQUITの意味を反転させる
die-on-term = true

# memory-reportを有効化
memory-report = true

必要なファイルを作成

Procfile(heroku上で実行されるコマンドを記述するファイル)を作成します。

web: uwsgi uwsgi.ini

runtime.txtを作成し,中にプログラミング言語とバージョンを入れておきます

python-3.8.1

requirements.txtを作成しておき,アプリに必要なライブラリを書いておきます。

pandas
scikit-learn
lightgbm
flask
uwsgi

Herokuへデプロイ

Webアプリがおいてある場所をGitのリポジトリにし,追跡してコミットしておきます。

git init
git add .
git commit -m "first commit"

Herokuにアプリをつくります。

heroku create

Herokuにプッシュします。

git push heroku master

アプリを確認

heroku open

でデプロイしたアプリをブラウザで開いてくれるので,アプリのURLを確認できます。

以前作成したAPIのunittestのURL部分をデプロイしたアプリのURLに書き換えて,テストをしてみます

$ python3 api_test.py
{"predicted":45833222.1903707,"status":"OK"}

.
----------------------------------------------------------------------
Ran 1 test in 0.833s

OK

無事に結果が返ってきました。

herokuのlogを

 heroku logs --tail

で見ても,POSTリクエストに対応したことが確認できました。

参考

Running python webapps on Heroku with uWSGI — uWSGI 2.0 documentation

Python | Heroku Dev Center

今回使用したコード

github.com