ラベル R の投稿を表示しています。 すべての投稿を表示
ラベル R の投稿を表示しています。 すべての投稿を表示

2020年9月18日金曜日

さけのわデータで遊んでみる

日本酒に関するデータを Web API で提供してくれている さけのわデータ【生配信】さけのわデータを見て楽しむ で紹介されていたので試してみる。


1. さけのわデータ


利用規約を遵守しつつデータの取得を行う。

1.1 利用規約


サイト上 の利用規約によると 『さけのわデータにて提供されるデータを利用するのに必要なのは 帰属表示 (後述) だけです』 かつ

  • 無料です
  • 商用、非商用を問わず利用できます
  • データを加工して利用できます

との事なので規約に従って帰属表示をちゃんと表示してみる。

〜この記事では さけのわデータ を利用しています〜

ありがとうございます。

1.2 データ取得


httr パッケージで Web API からデータを取得し jsonlite パッケージで json から data.frame への変換を行う。
※データフォーマットに関しては さけのわデータプロジェクト を参照
library(tidyverse)
library(httr)
library(jsonlite)

# 銘柄マスタ
df.brands <-

  # API を叩いて json データを取得
  httr::GET("https://muro.sakenowa.com/sakenowa-data/api/brands") %>%
  httr::content(as = "text", encoding = "utf-8") %>%

  # json を data.frame に変換
  jsonlite::fromJSON() %>%
  .$brands %>%

  # data.frame を tibble に変換
  tibble::as_tibble()

# 醸造所マスタ
df.breweries <-

  # API を叩いて json データを取得
  httr::GET("https://muro.sakenowa.com/sakenowa-data/api/breweries") %>%
  httr::content(as = "text", encoding = "utf-8") %>%

  # json を data.frame に変換
  jsonlite::fromJSON() %>%
  .$breweries %>%

  # data.frame を tibble に変換
  tibble::as_tibble()

# 地域マスタ
df.areas <-

  # API を叩いて json データを取得
  httr::GET("https://muro.sakenowa.com/sakenowa-data/api/areas") %>%
  httr::content(as = "text", encoding = "utf-8") %>%

  # json を data.frame に変換
  jsonlite::fromJSON() %>%
  .$areas %>%

  # data.frame を tibble に変換
  tibble::as_tibble()


# 銘柄ごとのフレーバーデータ
df.flavor_charts <-

  # API を叩いて json データを取得
  httr::GET("https://muro.sakenowa.com/sakenowa-data/api/flavor-charts") %>%
  httr::content(as = "text", encoding = "utf-8") %>%

  # json を data.frame に変換
  jsonlite::fromJSON() %>%
  .$flavorCharts %>%

  # data.frame を tibble に変換
  tibble::as_tibble()

# ランキングデータ
df.rankings <-

  # API を叩いて json データを取得
  httr::GET("https://muro.sakenowa.com/sakenowa-data/api/rankings") %>%
  httr::content(as = "text", encoding = "utf-8") %>%

  # json を data.frame に変換
  jsonlite::fromJSON() %>%

  # 総合ランキングを対象(地域ランキングは今回は対象外)
  .$overall %>%

  # data.frame を tibble に変換
  tibble::as_tibble()


2. クラスタリング


銘柄ごとのフレーバー値(f1〜f6)で k-means によるクラスタリングを行う。

2.1 クラスタ数の推定


cluster パッケージを用いて良さげなクラスタ数を推定する。
※参考: R K-means法のクラスタ数を機械的に決定する方法
set.seed(1025)

clsgap.flavor_charts <- df.flavor_charts %>%

  # フレーバーデータ以外を除去
  dplyr::select(-brandId) %>%

  # 標準化の実施
  scale() %>%

  # 推定に用いる統計量を算出
  # K.max: 推定するクラスタ数の上限
  # B: モンテカルロ法のブートストラップ回数(らしい)
  # ※k-means の収束を安定させるために kmeans 関数の引数 iter.max を大きめに指定している事に注意
  cluster::clusGap(kmeans, K.max = 10, B = 100, iter.max = 100)

# 結果の確認
# 今回はクラスタ数 5 が推定されている
clsgap.flavor_charts
# Clustering Gap statistic ["clusGap"] from call:
# cluster::clusGap(x = ., FUNcluster = kmeans, K.max = 10, B = 100, iter.max = 100)
# B=100 simulated reference sets, k = 1..10; spaceH0="scaledPCA"
#  --> Number of clusters (method 'firstSEmax', SE.factor=1): 5  # 推定されたクラスタ数

2.2 クラスタリングの実施


上記で推定されたクラスタ数 5 を用いて k-means によるクラスタリングを実施。
km.flavor_charts <- df.flavor_charts %>%

  # フレーバーデータ以外を除去
  dplyr::select(-brandId) %>%

  # 標準化の実施
  scale() %>%

  # matrix から tibble へ変換
  tibble::as_tibble() %>%

  # k-means の実施
  # nstart: ランダムにクラスタリングを実施する回数(デフォルトの 1 だと結果が安定しない事が多いので 5〜10 程度を指定する方が良い(と思う))
  kmeans(centers = 5, iter.max = 100, nstart = 5)
km.flavor_charts$centers で取得できる 5 つのクラスタ中心の特徴を確認する。
km.flavor_charts$centers %>%

  # matrix を tibble に変換
  # rownames を指定する事で matrix の表側に指定された内容を列として追加可能
  tibble::as_tibble(rownames = "cluster") %>%
  dplyr::mutate(cluster = factor(cluster)) %>%

  # => long-form
  tidyr::pivot_longer(cols = -cluster, names_to = "feature", values_to = "score") %>%

  # 可視化
  ggplot(aes(feature, cluster)) +
    geom_tile(aes(fill = score), colour = "black", show.legend = F) +
    geom_text(aes(label = round(score, 2)), size = 5, alpha = 1/2) +
    scale_fill_gradient(low = "white", high = "tomato") +

    # 余白を除去
    coord_cartesian(expand = F)

横軸に各フレーバー、縦軸に各クラスタを配置。セル内の数値は標準化処理済みのフレーバー平均値。
各クラスタごとにうまく特徴を抽出できているような感じはある。
※各フレーバー f1〜f6 の中身に関しては さけのわデータプロジェクト を参照

  • クラスタ 1
    • f4(穏やか)が高め
  • クラスタ 2
    • f3(重厚)が高め
    • f6(軽快)が低め
  • クラスタ 3
    • f2(芳醇)が若干高めだが概ね平均的
  • クラスタ 4
    • f1(華やか)が高め
    • f6(軽快)が高め
  • クラスタ 5
    • f5(ドライ)が高め


さらにクラスタごとに各フレーバーの分布を確認する事を考える。
df.flavor_charts %>%

  # クラスタ番号を追加
  dplyr::mutate(cluster = forcats::as_factor(km.flavor_charts$cluster)) %>%

  # => long-form
  tidyr::pivot_longer(cols = dplyr::starts_with("f"), names_to = "feature", values_to = "score") %>%

  # 可視化
  ggplot(aes(feature, score)) +
    geom_boxplot() +
    facet_grid(cluster ~ .)
横軸に各フレーバー、縦軸にオリジナルのフレーバー値(0〜1)を表示。
概ね前述したクラスタ中心に沿って各クラスタごとに特徴的な分布をしている事が見て取れる。
クラスタ 3 は際立った特徴が無く平均的な銘柄を中心とするクラスタである事がよく分かる。



3. 銘柄全体の可視化


6 項目(次元)のフレーバーデータを平面上で表現するために第 1&2 主成分スコアを用いて各銘柄を配置する事を考える。

3.1 主成分分析


銘柄ごとのフレーバー値を用いて主成分分析を実施する。
第 1&2 主成分で約 70% の寄与率であり、それなりに特徴を捉えた表現が出来る可能性あり。
pca.flavor_charts <- df.flavor_charts %>%

  # フレーバーデータ以外を除去
  dplyr::select(-brandId) %>%

  # 主成分分析を実施
  # scale = T で標準化の実施を指定
  prcomp(scale = T)

# 結果を確認
summary(pca.flavor_charts)
# Importance of components:
#                           PC1    PC2    PC3     PC4     PC5     PC6
# Standard deviation     1.6518 1.2178 0.9635 0.71732 0.48521 0.33189
# Proportion of Variance 0.4548 0.2472 0.1547 0.08576 0.03924 0.01836
# Cumulative Proportion  0.4548 0.7019 0.8567 0.94240 0.98164 1.00000
pca.flavor_charts$rotation で取得できる各種成分ごとの固有ベクトルを用いて第 1&2 主成分の特徴を確認する。
pca.flavor_charts$rotation %>%

  # matrix を tibble に変換
  # rownames を指定する事で matrix の表側に指定された内容を列として追加可能
  tibble::as_tibble(rownames = "flavor") %>%

  # => long-form
  tidyr::pivot_longer(cols = -flavor, names_to = "PC", values_to = "score") %>%

  # 可視化
  ggplot(aes(flavor, score)) +

    # 第 3〜6 主成分をグレーで表示
    geom_line(aes(group = PC), colour = "gray", data = function(df) { dplyr::filter(df, PC %in% stringr::str_c("PC", 3:6, sep = "")) }, alpha = 1/2) +
    geom_point(colour = "gray", data = function(df) { dplyr::filter(df, PC %in% stringr::str_c("PC", 3:6, sep = "")) }, alpha = 1/2) +

    # 第 1&2 主成分のみを強調表示
    geom_line(aes(group = PC, colour = PC), data = function(df) { dplyr::filter(df, PC %in% stringr::str_c("PC", 1:2, sep = "")) }, size = 1, alpha = 2/3) +
    geom_point(aes(colour = PC), data = function(df) { dplyr::filter(df, PC %in% stringr::str_c("PC", 1:2, sep = "")) }, size = 3)
ざっくり第 1 主成分が軽さ、第 2 主成分が辛口の度合いという感じ?(適当)
※各フレーバー f1〜f6 の中身に関しては さけのわデータプロジェクト を参照


3.2 銘柄の配置


算出した第 1&2 主成分を用いて銘柄を配置していく。
df.flavor_charts %>%

  # 各主成分スコアを追加
  dplyr::bind_cols(tibble::as_tibble(pca.flavor_charts$x)) %>%

  # クラスタ番号を追加
  dplyr::mutate(cluster = forcats::as_factor(km.flavor_charts$cluster)) %>%

  # 銘柄の名称を追加
  dplyr::left_join(df.brands, by = c("brandId" = "id")) %>%

  # ランキング(トップ 50)を追加
  dplyr::left_join(df.rankings, by = "brandId") %>%

  dplyr::select(
    brand_name = name,
    PC1,
    PC2,
    cluster,
    rank
  ) %>%

  # 可視化
  ggplot(aes(PC1, PC2)) +

    # 銘柄の名称を追加
    geom_text(aes(label = brand_name, colour = cluster), family = "Osaka", size = 3, alpha = 2/3) +

    # クラスタごとの中心(平均値)を追加
    geom_point(
      aes(x = PC1, y = PC2, fill = cluster),
      data = function(df) {
        dplyr::group_by(df, cluster) %>%
          dplyr::summarise(dplyr::across(dplyr::starts_with("PC"), mean))
      },
      size = 5,
      shape = 24 # 表示を▲に指定
    ) +

    # ランキング(トップ 50)を追加
    geom_text(aes(label = rank), data = function(df) { dplyr::filter(df, dplyr::between(rank, 1, 50)) }, size = 5, alpha = 2/3)

横軸を第 1 主成分、縦軸を第 2 主成分として各銘柄の位置を表現した。
5 つの三角(▲)は各クラスタの重心(平均値)の位置を表しており、黒文字の数字はランキング対象銘柄の位置を表す。
概ね各クラスタごとに配置が分離されており、第 1&2 主成分でそれなりに特徴を捉える事が出来ているものと思われる。
ランキングのトップ 50 に入るような人気銘柄の多くがクラスタ 3 と 4 の領域に存在しており、 辛口を避け(=下半分)軽い飲み心地(=やや右寄り)の尖りすぎない(=原点に近い)銘柄が好まれる傾向にあるのではないかと考えられる。


ランキングをトップ 10 に絞ってみると上記の傾向がより顕著となり、仮説としてはそれなりに悪くないのかも。



4. 決定木


ランクを数量と見做して回帰を行う事の是非は一旦見ないふりをして各ランクを分類するための決定木を構築する。
今回は ggparty パッケージを使用して ggplot 上でグラフを作成する(決定木をキレイに可視化できるようになって嬉しい)。
※ggparty の詳細に関しては ggparty: Graphic Partying を参照

各エッジ上に分割の条件となる数値が表示されるが、現状では簡単に丸め処理を行う方法が無い(恐らく)ので ggparty 作者が作成した非公式の add_splitvar_breaks_index_new 関数をダウンロードして用いている。
※詳細は Number of decimal places on edges of a decision-tree plot with ggparty 参照
library(ggparty)

# github 上の add_splitvar_breaks_index_new 関数をロードする
# エッジ上のラベルに表示する数値を丸めるために用いる
fnc <- readr::read_file("https://raw.githubusercontent.com/martin-borkovec/ggparty/martin/R/add_splitvar_breaks_index_new.R") %>%
  parse(text = .)
eval(fnc)


df.flavor_charts %>%

  # 各主成分スコアを追加
  dplyr::bind_cols(tibble::as_tibble(pca.flavor_charts$x)) %>%

  # クラスタ番号を追加
  dplyr::mutate(cluster = forcats::as_factor(km.flavor_charts$cluster)) %>%

  # ランキングの追加
  dplyr::inner_join(df.rankings, by = "brandId") %>%

  dplyr::select(-c(
    brandId,
    score
  )) %>%

  # 決定木を構築
  rpart::rpart(rank ~ ., data = ., minbucket = 5)%>%
  partykit::as.party() %>%

  {
    pt <- (.)

    # ノード分割に用いる数値の表示を丸める
    rounded_labels <- add_splitvar_breaks_index_new(
      party_object = pt,
      plot_data = ggparty:::get_plot_data(pt),
      round_digits = 3
    )


    # 可視化
    ggparty(pt) +

      geom_edge() +
      geom_edge_label(aes(label = unlist(rounded_labels)), size = 3) +

      # ノード分割に用いる項目を追加
      geom_node_label(
        aes(colour = splitvar),
        line_list = list(
          aes(label = splitvar),
          aes(label = stringr::str_c("N = ", nodesize, sep = ""))
        ),
        line_gpar = list(
          list(size = 12, fontface = "bold"),
          list(size = 9, colour = "black")
        ),
        show.legend = F,
        ids = "inner"
      ) +

      # 終端ノードに箱ひげ図を指定
      geom_node_plot(
        gglist = list(
          geom_boxplot(aes(x = "", y = rank)),
          labs(
            x = NULL
          )
        ),
        nudge_x = -0.01,
        shared_axis_labels = T
      ) +

      # 終端ノードの件数を追加
      geom_node_label(
        line_list = list(
          aes(label = stringr::str_c("N = ", nodesize, sep = ""))
        ),
        line_gpar = list(
          list(size = 9, colour = "black")
        ),
        nudge_y = -0.4,
        ids = "terminal"
      )
  }

  • f3(重厚)が 0.256(下位25%)〜0.276(下位35%)に位置する銘柄群が最も人気の高いクラス(一番左)として識別されており、これは第 1 主成分 PC1 の f3 が負の方向に大きく特徴づけられている事とも整合性がとれている
  • f3(重厚)が 0.276(下位35%)以上であり、PC1 のスコアがそれなりに低い(-0.738以下)銘柄群が最も人気の低いクラス(一番右)として識別されている事もこれまで見てきた特徴と整合性がとれている
※上記の人気の高い/低いは人気 Top 50 の中での相対的なものである事に注意



5. まとめ

  • さけのわデータは利用規約が緩く使い勝手が良い
  • json ベースの Web API は R でも簡単に扱える
  • 人気銘柄の傾向をそれなりに捉えられて楽しい
  • 今回は使用していないフレーバータグも試してみたい

参考

2020年9月9日水曜日

ベルヌーイ施行における最尤推定と事後分布

複数回のベルヌーイ施行においてパラメータ $\theta = p$ を最尤推定および事後分布(からの点推定)で推定する際に
  • どの程度の差異が発生するのか
  • どのように差異が収斂していくのか
をシミュレーションで検証する。

この記事は以前に記載した ベルヌーイ分布と事後分布 の 「1.1.3 無情報事前分布における MAP と MLE の一致」 で簡潔に述べた内容を含み、より広い条件で検証するものである。


1. 導出された結果(再掲)


以前の記事で導出した結果のみを下記に記載する。
※詳細な定義および導出の過程は ベルヌーイ分布と事後分布 を参照

  • $p(\theta | x^{n})$: 事後分布
  • $x_{j}$: サンプルデータ
  • $n$: サンプル数
  • $a, b$: 事前分布 $Beta(a,b)$ のパラメータ

\begin{eqnarray} p(\theta | x^{n}) &=& Beta(a + \sum_{j=1}^{n} x_{j}, n + b - \sum_{j=1}^{n} x_{j}) \\ \hat{\theta}_{MLE} &=& \frac{1}{n} \sum_{j=1}^{n} x_{j} \\ \hat{\theta}_{MAP} &=& \frac{a + \sum x_{j} - 1}{n + a + b - 2} \\ \hat{\theta}_{EAP} &=& \frac{ a + \sum x_{j} }{ n + a + b } \end{eqnarray}

2. 事前分布


上記ではベルヌーイ施行における事前分布としてベータ分布を仮定しており、以下ではパラメータ $a, b$ を 2 パターン設定して検証を行う。
  1. (a, b) = (1, 1)
  2. (a, b) = (3, 6)
上記 1. は無情報事前分布であり下記の $Beta(1,1)$ に示すように定義域が $[0, 1]$ の一様分布となる。
library(tidyverse)

# Beta 分布のパラメータ
lst.beta <- list(
  a = 3,
  b = 6
)

tibble(
  p = seq(0, 1, 0.01),
  d_1_1 = dbeta(0, 1, 1),                  # Beta(1,1)
  d_a_b = dbeta(p, lst.beta$a, lst.beta$b) # Beta(a,b)
) %>%

  # to wide-form
  tidyr::pivot_longer(cols = -p, names_to = "param", values_to = "density") %>%

  # 可視化
  ggplot(aes(p, density)) +
    geom_line() +
    labs(y = NULL) +
    facet_grid(
      . ~ param,
      labeller = labeller(
        param = as_labeller(c(
          "d_1_1" = "B(1,1)",
          "d_a_b" = stringr::str_c("Beta(", lst.beta$a, ",", lst.beta$b, ")")
        ))
      )
    )



2. データの生成


真のパラメータ $p = 0.3$ を指定して 1,000 個の TRUE/FALSE からなる乱数 $x$ を生成する。
set.seed(1025)

n <- 1000
p <- 0.3

# データ生成
x <- rbernoulli(n, p)

# 先頭 10 件の確認
x[1:10]
# FALSE FALSE FALSE FALSE FALSE TRUE TRUE TRUE FALSE FALSE

# x の平均値
# 真のパラメータ p=0.3 にかなり近い
mean(x)
# 0.29

上記 $x$ を用いて各累積施行ごとにパラメータ $p$ を推定していく。
※コメント内の番号 (1)〜(4) は 「1. 導出された結果(再掲)」 における数式の番号に紐づく
df.estimations <- tibble(
  # 延べ試行回数
  n = 1:length(x),
  
  # 成功: 1
  # 失敗: 0
  x = as.integer(x),

  # 延べ成功回数
  cumsum_x = cumsum(x),

  # 最尤推定による点推定値 - (2)
  MLE = cumsum_x / n,

  # MAP - (3)
  MAP_1_1 = (1 + cumsum_x - 1) / (n + 1 + 1 -2),                            # 事前分布: Beta(1,1)
  MAP_a_b = (lst.beta$a + cumsum_x - 1) / (n + lst.beta$a + lst.beta$b -2), # 事前分布: Beta(3,6)

  # EAP - (4)
  EAP_1_1 = (1 + cumsum_x) / (1 + 1 + n),                                   # 事前分布: Beta(1,1)
  EAP_a_b = (lst.beta$a + cumsum_x) / (lst.beta$a + lst.beta$b + n),        # 事前分布: Beta(3,6)

  # 事後分布の算出 - (1)
  post_dists = purrr::map2(n, cumsum_x, function(n, cumsum_x, a, b) {
    tibble::tibble(
      p = seq(0, 1, 0.01),
      post_d_1_1 = dbeta(p, 1 + cumsum_x, n + 1 - cumsum_x),                # 事前分布: Beta(1,1)
      post_d_a_b = dbeta(p, a + cumsum_x, n + b - cumsum_x)                 # 事前分布: Beta(3,6)
    )
  }, a = lst.beta$a, b = lst.beta$b)
)

df.estimations
      n             x       cumsum_x MLE MAP_1_1 MAP_a_b EAP_1_1 EAP_a_b post_dists
1 0 0 0 0 0.25 0.333 0.3 <tibble [101 × 3]>
2 0 0 0 0 0.222 0.25 0.273 <tibble [101 × 3]>
3 0 0 0 0 0.2 0.2 0.25 <tibble [101 × 3]>
4 0 0 0 0 0.182 0.167 0.231 <tibble [101 × 3]>
5 0 0 0 0 0.167 0.143 0.214 <tibble [101 × 3]>


上記 post_dists 項目には各行(累積施行)ごとに事後分布のデータが tibble 形式で格納されており、項目を指定しないと確認する事はできない。
下記は 1 回目の施行によりデータを 1 つ獲得した後の事後分布を 5 行だけ表示したものであり、post_d_xxx は確率密度を表す。

df.estimations[1,]$post_dists[[1]]
      p       post_d_1_1 post_d_a_b
0 2 0
0.01 1.98 0.0237
0.02 1.96 0.0893
0.03 1.94 0.189
0.04 1.92 0.316


3. 事後分布の確認


各施行ごとの事後分布を確認していく。

3.1 少ない試行回数での挙動


試行回数が少ない時の挙動を確認する。主に 1〜10 回をメインに観察する。
df.estimations%>%

  # 試行回数が特定の時点のみを対象
  dplyr::filter(n %in% c(1, 2, 3, 4, 5, 10, 25, 50, 100)) %>%

  # 事後分布の抽出
  tidyr::unnest(post_dists) %>%
  dplyr::select(n, MLE, p, post_d_1_1, post_d_a_b) %>%

  # => long-form
  tidyr::pivot_longer(cols = c(post_d_1_1, post_d_a_b), names_to = "type", values_to = "density") %>%

  # 可視化
  ggplot(aes(p, density)) +
    geom_line(aes(colour = type), alpha = 1, show.legend = F) +
    geom_vline(aes(xintercept = MLE), size = 1.0, linetype = "solid", colour = "gray") +
    labs(
      x = NULL,
      y = NULL
    ) +
    facet_grid(
      n ~ type,
      scale = "free_y",
      labeller = labeller(
        type = as_labeller(c(
          "post_d_1_1" = "Beta(1,1)",
          "post_d_a_b" = stringr::str_c("Beta(", lst.beta$a, ",", lst.beta$b, ")")
        ))
      )
    )

左列には $Beta(1,1)$ を事前分布(無情報事前分布)とする事後分布が、右列には $Beta(3,6)$ を事前分布とする事後分布が表示されている。
右側の番号は試行回数を表しており、下に進むほど試行回数が大きくなる。
各セルにおける横軸上の三角は MLE を表しており、各行ごとに左右の列で同じ位置に表示されている事に注意。


上記より把握できる事を下記にまとめる。
  • 試行回数が少ない場合に MLE はデータに過度に反応し、試行回数 1〜5 では p = 0 という極端な値を提示してしまっている
  • 左列である Beta(1,1) (無情報事前分布)側におけるピークの位置(MAP) と MLE は一致している(理論上完全に一致する)
  • 最初の 5 回で FALSE が続いている事を受けて Beta(1,1) 側はデータに過度に反応した事後分布を生成してしまっている
  • 最初の 5 回の FALSE で Beta(1,1) 側は試行回数が増える程に p = 0 の確信が高まっている様子が伺える
  • 試行回数が少ない時点では Beta(1,1) 側と Beta(3,6) 側では差異が大きい
  • 試行回数が大きくなるにつれ Beta(1,1) 側と Beta(3,6) 側の差異は縮まる傾向にある
  • Beta(1,1) 側および Beta(3,6) 側の両方で、試行回数が増えるにつれてピーク付近でのばらつきが減少している(横が狭くなる)事およびピークの位置が高くなっている事から段々と推定の確信が高まっている様子が伺える

3.2 事前分布による差異


$Beta(1,1)$ と $Beta(3,6)$ の異なる事前分布における挙動の差異を確認する。
df.estimations %>%

  dplyr::select(n, MAP_1_1, MAP_a_b, EAP_1_1, EAP_a_b) %>%

  # => long-form
  tidyr::pivot_longer(cols = c(MAP_1_1, MAP_a_b, EAP_1_1, EAP_a_b), names_pattern = "(MAP|EAP)_(.+_.+)", names_to = c("type", "param"), values_to = "prob") %>%

  # 可視化
  ggplot(aes(n, prob)) +
    geom_line(aes(color = param), alpha = 1/2) +
    geom_point(aes(colour = param), size = 0.75, alpha = 1/5) +

    # 真のパラメータ 0.3 を表示
    geom_hline(yintercept = 0.3, alpha = 1/3) +

    scale_y_continuous(limits = c(0, 0.5)) +
    scale_colour_hue(
      name = "Prior Dist",
      labels = c(
        "1_1" = "Beta(1,1)",
        "a_b" = stringr::str_c("Beta(", lst.beta$a, ",", lst.beta$b, ")")
      )
    ) +
    labs(
      x = "Trials",
      y = "Estimated Parameter"
    ) +
    facet_grid(type ~ .)

横軸に試行回数、縦軸にパラメータ p の推定値を表示する。
水平に伸びた直線はパラメータ p の真の値 0.3 を示している。
上下で推定値が異なる事に注意。


  • Beta(1,1) (無情報事前分布)を用いた推定値の方が上下の振れ幅が大きい傾向にある
  • MAP/EAP ともに試行回数が少ない範囲では事前分布の違いにより挙動が大きく異る
  • 試行回数が大きくなると事前分布による差異はほぼ見られくなる

3.3 点推定値による差異


MAP と EAP における挙動の差異を確認する。
df.estimations %>%

  dplyr::select(n, MAP_1_1, MAP_a_b, EAP_1_1, EAP_a_b) %>%

  # => long-form
  tidyr::pivot_longer(cols = c(MAP_1_1, MAP_a_b, EAP_1_1, EAP_a_b), names_pattern = "(MAP|EAP)_(.+_.+)", names_to = c("type", "param"), values_to = "prob") %>%

  # 可視化
  ggplot(aes(n, prob)) +
    geom_line(aes(color = type), alpha = 1/2) +
    geom_point(aes(colour = type), size = 0.75, alpha = 1/5) +

    # 真のパラメータ 0.3 を表示
    geom_hline(yintercept = 0.3, alpha = 1/3) +

    scale_y_continuous(limits = c(0, 0.5)) +
    labs(
      colour = NULL,
      x = "Trials",
      y = "Estimated Parameter"
    ) +
    facet_grid(
      param ~ .,
      labeller = labeller(
        param = as_labeller(c(
          "1_1" = "Beta(1,1)",
          "a_b" = stringr::str_c("Beta(", lst.beta$a, ",", lst.beta$b, ")")
        ))
      )
    )

横軸に試行回数、縦軸にパラメータ p の推定値を表示する。
水平に伸びた直線はパラメータ p の真の値 0.3 を示している。
上下で事前分布が異なる事に注意。


  • $Beta(1,1)$/$Beta(3,6)$ ともに試行回数が少ない範囲では推定量ごとの挙動に差異が見られる
  • 試行回数が大きくなると推定量による差異はほぼ見られくなる
  • EAP(赤色) > MAP(青色) の傾向がある(3.2.1 で後述)

3.2.1 EAP > MAP について


特定の状況においてほぼ EAP > MAP が成立する事を示す。
以下では EAP と MAP の差分を考える事で上記を検証していく。

\begin{eqnarray} \hat{\theta}_{EAP} - \hat{\theta}_{MAP} = \frac{ a + \sum x_{j} }{ n + a + b } - \frac{a + \sum x_{j} - 1}{n + a + b - 2}. \nonumber \end{eqnarray} 上記は分母が基本的に正となるので、計算を楽にするため以下では分子だけを考える。 \begin{eqnarray} 分子 &=& \{ (a + \sum x_{j})(n + a + b - 2) - (a + \sum x_{j} - 1)(n + a + b) \} \nonumber \\ &=& \Bigl[ \bigl\{ (a + \sum x_{j} - 1)(n + a + b - 2) + (n + a + b - 2) \bigr\} - (a + \sum x_{j} - 1)(n + a + b) \Bigr] \nonumber \\ &=& \{ (a + \sum x_{j} - 1)(- 2) + (n + a + b - 2) \} \nonumber \\ &=& \{ (b - a) + 2n (\frac{1}{2} - \frac{1}{n} \sum x_{j}) \} \nonumber \end{eqnarray} 上記より、パラメータ $p$ の不偏推定量である標本平均 $\frac{1}{n} \sum x_{j}$ が $\frac{1}{n} \sum x_{j} < \frac{1}{2}$ を満たせばほぼ $\hat{\theta}_{EAP}>\hat{\theta}_{MAP}$ が成立する。
今回はベルヌーイ分布の真のパラメータが $p = 0.3$ であり $\frac{1}{2}$ より小さい事からほぼ \begin{eqnarray} \therefore \hat{\theta}_{EAP} > \hat{\theta}_{MAP} \nonumber \end{eqnarray} が成立するものと考えられる(適当)。

3.3 試行回数による分布の変化


試行回数の増加に伴う分布の変化をマクロな視点で確認する。
df.estimations %>%

  dplyr::select(n, post_dists) %>%

  # 事後分布の抽出
  tidyr::unnest(post_dists) %>%

  # => long-form
  tidyr::pivot_longer(cols = c(post_d_1_1, post_d_a_b), names_to = "param", values_to = "density") %>%

  # 可視化
  ggplot(aes(n, p)) +
    geom_tile(aes(fill = density), show.legend = F) +
    scale_fill_gradient(low = "#132B43", high = "tomato") +
    labs(
      x = "Trials",
      y = "p"
    ) +
    facet_grid(
      param ~ .,
      labeller = labeller(
        param = as_labeller(c(
          "post_d_1_1" = "Beta(1,1)",
          "post_d_a_b" = stringr::str_c("Beta(", lst.beta$a, ",", lst.beta$b, ")")
        ))
      )
    ) +

    # 不要な余白を除去
    coord_cartesian(expand = F)

横軸に試行回数、縦軸にパラメータ p を表示し、各点における事後分布の確率密度を色で表現する。
黒に近いほど確率密度が低く、赤く明るいほど確率密度が高い事を示す。
上下で事前分布が異なる事に注意。


  • 全体を外観すると事前分布が異なる事による差異はほぼ見られない
  • 左端の方では明確な赤色が見られず全体としてぼやけた感じとなっている
  • 右側に進むにつれて赤と黒が明確に分離していく様子が見て取れる
  • 試行回数の増加と共にパラメータ推定の確信度が上がっていると考えられる
  • 試行回数が大きくなると分布に殆ど変化が見られなくなり、多少のデータ傾向の揺れに振り回されなくなってくる(左端の挙動と対称的)

上記から、ある程度の試行回数が存在しない限りパラメータ p の推定はあまり意味を持たない可能性があると思われる。


まとめ

  • サンプルが少ない場合に、MLE はデータに過度に反応し、データによってはかなり極端な値を提示してしまう
  • サンプルが少ない場合に、推定された事後分布はデータに過度に反応する可能性がある
  • サンプルが少ない場合に、推定された事後分布は事前分布の違いに大きく影響を受ける可能性がある
  • サンプルが少ない場合に、点推定量(MLE/MAP/EAP)による違いが現れる可能性がある
  • サンプルが増加するにつれ、推定される事後分布が多少のデータ傾向の揺れに影響を受けづらくなり安定する
  • サンプルが増加するにつれ、推定される事後分布は事前分布の違いにあまり影響を受けなくなる
  • サンプルが増加するにつれ、点推定量の違いによる差異はほぼ見られなくなる
  • ある程度のサンプルが存在しないとパラメータの推定はかなり厳しい

2020年6月2日火曜日

不均衡データのダウンサンプリングとキャリブレーション

ダウンサンプリングしたデータで 2 値分類を行う際に混入するバイアスの除去を検討する。
基本的には この論文ダウンサンプリングによる予測確率のバイアス を参考にしている。

データの読み込み


MNIST データを用いて特定のラベル(今回は 5)を特定する 2 値分類の問題を考える。
Kaggle のサイト から train.csv をダウンロードして取り込む。
library(tidyverse)

# 保存したファイルを読み込む
df.mnist <- readr::read_csv(
  file = "path/to/dir/train.csv",
  col_types = cols(
    .default = col_integer()
  )
) %>%

  dplyr::mutate(
    # ラベル(正解)が "5" かそれ以外かで分類しカテゴリ値に変換
    # 後の yardstick::mn_log_loss の仕様により正解(T)をカテゴリの最初の水準に指定する必要がある事に注意
    y = (label == 5) %>% factor(levels = c(T, F))
  ) %>%
  dplyr::select(-label)

ダウンサンプリング


recipes::step_downsample を用いて正解ラベル(y) が False であるレコードのダウンサンプリングを行う。
under_ratio = 1 の指定により正解ラベル(y)が「TRUE : FALSE = 1 : 1」となるようにサンプリングが実施される。
※詳細は ドキュメント を参照。
# 乱数 seed の固定
# rsample::initial_split 用
set.seed(1025)

lst.splitted <-

  # データを train/test の 2 つに分類
  rsample::initial_split(df.mnist, prop = 3/4, strata = "y") %>%

  {
    split <- (.)

    # ダウンサンプリング用の recipe を定義
    recipe <- rsample::training(split) %>%
      recipes::recipe(y ~ ., .) %>%
      recipes::step_downsample(y, under_ratio = 1, seed = 1025)

    list(
      train = rsample::training(split),
      train_sampled = recipes::prep(recipe, training = rsample::training(split)) %>% recipes::juice(),
      test  = rsample::testing(split)
    )
  }

学習の実施


モデルとして RandomForest を用いて学習を行う。
元データとサンプリング済データで学習時間に約 10 倍近い差が出ておりサンプリングによるメリットが大きく現れている。
  • 元データ: 約 220 秒 / 31,500 件
  • サンプリングデータ: 約 20 秒 / 5,646 件
model <- parsnip::rand_forest(
  mode = "classification",
  trees = 500,
  mtry = 300,
  min_n = 2
) %>%
  parsnip::set_engine(
    engine = "ranger",
    max.depth = 20,
    num.threads = 8,
    seed = 1025
  )

# 全データを用いた学習: 約 220 秒
system.time(
  fit.original <- model %>%
    parsnip::fit(y ~ ., lst.splitted$train)
)

# サンプリングデータを用いた学習:  約 20 秒
system.time(
  fit.downsampled <- model %>%
    parsnip::fit(y ~ ., lst.splitted$train_sampled)
)

キャリブレーション


キャリブレーションはオリジナルの予測確率を $p_{s}$ として下記で与えられる。 \begin{eqnarray} p = \frac{ p_{s} }{ p_{s} + \frac{ 1 - p_{s} }{ \beta } } \nonumber \end{eqnarray} ここで $\beta = p(s = 1 | −)$ により $\beta$ は負例におけるサンプリング割合であり、下記により $\beta \fallingdotseq 0.0984$ となる。
  • 訓練データ: 31,500 件
  • サンプリングデータ: 5,646 件
    • y = TRUE: 2,823 件
    • y = FALSE: 2,823 件
  • 非サンプリングデータ: 31,500 - 5,646 = 25,854 件
  • 2,823 / (2,823 + 25,854) ≒ 0.0984
# 訓練データ全体: 31,500
nrow(lst.splitted$train)

# サンプリング対象: 5,646
nrow(lst.splitted$train_sampled)

# サンプリング対象の内訳
# - T: 2,823
# - F: 2,823
lst.splitted$train_sampled %>% dplyr::count(y)

# beta: 0.09844126
beta <- 2823 / (2823 + 25854)

予測データ


この後の可視化および評価に備えて予測確率を正解ラベルと共にまとめておく。
正例と判定するしきい値はデフォルトの 0.5 を用いている。
# 各予測確率の一覧
# 正例のしきい値: 0.5
df.predicted <- tibble(
  # 正解ラベル
  actual = lst.splitted$test$y,

  # 元データによる学習と予測
  original.proba = predict(fit.original, lst.splitted$test, type = "prob")$.pred_TRUE,
  original.pred  = (original.proba > 0.5) %>% factor(levels = c(T, F)),

  # サンプリングデータによる学習と予測
  downsampled.proba = predict(fit.downsampled, lst.splitted$test, type = "prob")$.pred_TRUE,
  downsampled.pred  = (downsampled.proba > 0.5) %>% factor(levels = c(T, F)),

  # キャリブレーション適用済みの予測
  calibrated.proba = downsampled.proba / (downsampled.proba + (1 - downsampled.proba) / beta),
  calibrated.pred  = (calibrated.proba > 0.5) %>% factor(levels = c(T, F))
)

df.predicted
actual original.proba original.pred downsampled.proba downsampled.pred calibrated.proba calibrated.pred
FALSE 0.0006013 FALSE 0.0091151 FALSE 0.0009047 FALSE
FALSE 0.0024995 FALSE 0.0100703 FALSE 0.0010004 FALSE
FALSE 0.1053711 FALSE 0.2660000 FALSE 0.0344460 FALSE
FALSE 0.0001306 FALSE 0.0199857 FALSE 0.0020035 FALSE
FALSE 0.0003486 FALSE 0.0040777 FALSE 0.0004029 FALSE


予測確率の分布


各予測確率の分布を可視化してみる。
df.predicted %>%

  # wide-form => long-form
  dplyr::select(
    original.proba,
    downsampled.proba,
    calibrated.proba
  ) %>%
  tidyr::pivot_longer(cols = dplyr::everything(), names_to = "type", names_pattern = "(.+)\\.proba", , values_to = "prob") %>%

  # 可視化時の並び順(facet_grid)を指定
  dplyr::mutate(type = forcats::fct_relevel(type, "original", "downsampled", "calibrated")) %>%

  # 可視化
  ggplot(aes(prob)) +
    geom_histogram(aes(y = ..density..), position = "identity", binwidth = 0.010, boundary = 0, colour = "white", alpha = 1/2) +
    geom_density(aes(fill = type), colour = "white", alpha = 1/3) +
    geom_vline(
      data = function(df) { dplyr::group_by(df, type) %>% dplyr::summarise(avg = mean(prob)) },
      aes(xintercept = avg, colour = type),
      linetype = 2,
      size = 1,
      alpha = 1/2
    ) +
    guides(fill = F, colour = F) +
    labs(
      x = "Probability",
      y = NULL
    ) +
    facet_grid(type ~ ., scales = "free_y")

上から順に [ サンプリングなし(original) / サンプリングのみ(downsampled) / キャリブレーション適用(calibrated) ] の順で並んでいる。
サンプリングのみの予測確率(downsampled)は正例側(右側)に寄っており、これがダウンサンプリングにおけるバイアスの影響だと考えられる。



Calibration Curve


0.1 刻みでビン化した予測確率の区間毎に予測確率(横軸)と正例(縦軸)の平均を算出して可視化を行う。
df.predicted %>%

  # ビン化
  dplyr::mutate(
    bins.downsampled = cut(downsampled.proba, breaks = seq(0, 1, 0.1)),
    bins.calibrated  = cut(calibrated.proba,  breaks = seq(0, 1, 0.1))
  ) %>%

  # 集約処理
  {
    data <- (.)

    # サンプリングのみ(downsampled)
    df.downsampled <- data %>%
      dplyr::group_by(bins = bins.downsampled) %>%
      dplyr::summarise(
        n = n(),
        avg_proba = mean(downsampled.proba),
        ratio = mean(actual == "TRUE")
      ) %>%
      dplyr::mutate(type = "downsampled")

    # calibration 適用(calibrated)
    df.calibrated <- data %>%
      dplyr::group_by(bins = bins.calibrated) %>%
      dplyr::summarise(
        n = n(),
        avg_proba = mean(calibrated.proba),
        ratio = mean(actual == "TRUE")
      ) %>%
      dplyr::mutate(type = "calibrated")

    dplyr::bind_rows(
      df.downsampled,
      df.calibrated
    )
  } %>%

  # 可視化
  ggplot(aes(avg_proba, ratio)) +
    geom_point(aes(size = n, colour = type), show.legend = F) +
    geom_line(aes(colour = type)) +
    geom_abline(slope = 1, intercept = 0, linetype = 2, alpha = 1/2) +
    scale_size_area() +
    labs(
      x = "Predict Probability",
      y = "Positive Ratio",
      colour = NULL
    )

左下から右上へ対角線上に伸びる黒い点線に沿うほど信頼性が高いと考えられるが、今回の calibration による実現はない。
見た感じでは 2 つの曲線がグラフの中心 (x, y) = (0.5, 0.5) に対して点対称になっており、 downsampled 曲線が予測確率の高い範囲(1.0 付近)で黒点線に沿っているのに対し calibrated 曲線が予測確率の低い範囲(0.0 付近)で黒点線に沿うような状況になっている事から今回の calibration によって正例と負例のどちらを重視するのかが入れ替わっているものと考えられる (ただの感想なので正しい解釈が欲しい…)。
ダウンサンプリングによって少数である正例側へと生じたバイアスを取り除くという意味では妥当と考えて良いのかもしれない。

下記の図では丸の大きさによって該当するレコード数を表現しており、左下の負例の辺りに大きなサイズの丸が存在している。これは正例の比率が小さい(約10%)事から発生していると考えられるが、件数の多い範囲(0.0 付近)における予測確率の精度が向上するという事はデータ全体における精度の向上として現れるのではないかと考えられる。実際この後に calibration を実施した予測確率において Log Loss の低下(=精度向上)が見られる事を確認する。




Log Loss


評価指標としてまず Log Loss の値を確認する。
# LogLoss: サンプリングなし
yardstick::mn_log_loss(df.predicted, actual, original.proba) %>%
  dplyr::select(metric = .metric, original = .estimate) %>%

  # LogLoss: サンプリングのみ
  dplyr::left_join(
    yardstick::mn_log_loss(df.predicted, actual, downsampled.proba) %>%
      dplyr::select(metric = .metric, downsampled = .estimate),
    by = "metric"
  ) %>%

  # LogLoss: キャリブレーション適用
  dplyr::left_join(
    yardstick::mn_log_loss(df.predicted, actual, calibrated.proba) %>%
      dplyr::select(metric = .metric, calibrated = .estimate),
    by = "metric"
  )

各指標(行)ごとに最も良いスコアと最も悪いスコアをそれぞれ赤と青で色付けしている。

サンプリングのみ(downsampled)の場合と比較して calibration によって明らかに Log Loss の改善(0.12=>0.06)が見られている。
予測確率の分布で見たように calibration によって分布が 0 と 1 の付近に寄るようになった事と、多数側のサンプルである負例での精度が向上している事に依るものと思われる。
しかしながらサンプリングなし(original)で最も良い結果が出ている事から、今回のデータで Log Loss が評価指標になる場合は不均衡への対応を行わないという選択肢は十分にある(他の対応方法を否定するものではない)。

Log Loss
metric original downsampled calibrated
mn_log_loss 0.0464 0.1223 0.0651


混同行列


  • 縦方向の T/F: Actual
  • 横方向の T/F: Predict
  • しきい値: 0.5

# サンプリングなし
table(df.predicted[, c("actual", "original.pred")])
TRUE FALSE
TRUE 848 124
FALSE 12 9,516

# サンプリングのみ
table(df.predicted[, c("actual", "downsampled.pred")])
サンプリングなしと比較して予測が正例(左列)側に寄っており、バイアスの影響が見られる。
TRUE FALSE
TRUE 944 28
FALSE 241 9,287

# キャリブレーション適用
table(df.predicted[, c("actual", "calibrated.pred")])
サンプリングのみの場合と比較して予測が全体的に負例(右列)側に寄っており、ある程度バイアスの解消が出来ているものと思われる。
一方で、False Negative(右上) に該当するサンプル数が大幅に増加してしまっている。
TRUE FALSE
TRUE 700 272
FALSE 5 9,523

Accuracy/Precision/Recall/F


しきい値は 0.5
# 使用する指標を指定
metrics <- yardstick::metric_set(
  yardstick::accuracy,
  yardstick::precision,
  yardstick::recall,
  yardstick::f_meas
)

# サンプリングなし(original)
metrics(df.predicted, truth = actual, estimate = original.pred) %>%
  dplyr::select(metric = .metric, original = .estimate) %>%

  # サンプリングのみ(downsampled)
  dplyr::left_join(
    metrics(df.predicted, truth = actual, estimate = downsampled.pred) %>%
      dplyr::select(metric = .metric, downsampled = .estimate),
    by = "metric"
  ) %>%

  # キャリブレーション適用(calibrated)
  dplyr::left_join(
    metrics(df.predicted, truth = actual, estimate = calibrated.pred) %>%
      dplyr::select(metric = .metric, calibrated = .estimate),
    by = "metric"
  )
各指標(行)ごとに最も良いスコアと最も悪いスコアをそれぞれ赤と青で色付けしている。

総合的には F 値(f_meas)の最も高いサンプリングなし(original)の場合が最も良い予測であると考えられる。
一方で calibration を適用するとサンプリングのみ(downsampled)の場合よりも成績が悪化してしまっている。
metric original downsampled calibrated
accuracy 0.987 0.974 0.974
precision 0.986 0.797 0.993
recall 0.872 0.971 0.720
f_meas 0.926 0.875 0.835


ここで判定に用いているしきい値を変更する事を考えてみる。
予測確率の分布においてその平均値が 0.1〜0.2 付近である事を考えると、上記で用いたしきい値の 0.5 はもう少し小さい値を取る方が良いと思われる。


しきい値の変更


# しきい値を 0.0〜1.0 の範囲で変更して各指標を算出
df.evals <- purrr::map_dfr(seq(0, 1, 0.01), function(threshold) {

  # 予測値の算出
  df.predicted <- tibble(
    actual = lst.splitted$test$y,

    # サンプリングなし
    original.proba = predict(fit.original, lst.splitted$test, type = "prob")$.pred_TRUE,
    original.pred  = (original.proba > threshold) %>% factor(levels = c(T, F)),

    # サンプリングのみ
    downsampled.proba = predict(fit.downsampled, lst.splitted$test, type = "prob")$.pred_TRUE,
    downsampled.pred  = (downsampled.proba > threshold) %>% factor(levels = c(T, F)),

    # キャリブレーション適用
    calibrated.proba = downsampled.proba / (downsampled.proba + (1 - downsampled.proba) / beta),
    calibrated.pred  = (calibrated.proba > threshold) %>% factor(levels = c(T, F))
  )

  # 評価指標
  metrics <- yardstick::metric_set(
    yardstick::accuracy,
    yardstick::precision,
    yardstick::recall,
    yardstick::f_meas
  )

  # サンプリングなし
  metrics(df.predicted, truth = actual, estimate = original.pred) %>%
    dplyr::select(metric = .metric, original = .estimate) %>%

    # サンプリングのみ
    dplyr::left_join(
      metrics(df.predicted, truth = actual, estimate = downsampled.pred) %>%
        dplyr::select(metric = .metric, downsampled = .estimate),
      by = "metric"
    ) %>%

    # キャリブレーション適用
    dplyr::left_join(
      metrics(df.predicted, truth = actual, estimate = calibrated.pred) %>%
        dplyr::select(metric = .metric, calibrated = .estimate),
      by = "metric"
    ) %>%

    # しきい値の追加
    dplyr::mutate(threshold = threshold)
})


df.evals %>%

  # wide-form => long-form
  tidyr::pivot_longer(cols = c(original, downsampled, calibrated), names_to = "type", values_to = "score") %>%

  # 可視化時の並び順を指定
  dplyr::mutate(
    metric = forcats::fct_relevel(metric, "accuracy", "precision", "recall", "f_meas"),
    type = forcats::fct_relevel(type, "original", "downsampled", "calibrated")
  ) %>%

  # 可視化
  ggplot(aes(threshold, score)) +
    geom_line(aes(colour = type)) +
    geom_vline(xintercept = c(0.15), linetype = 2, alpha = 1/2) +
    scale_x_continuous(breaks = seq(0, 1, 0.1)) +
    labs(
      x = "Threshold",
      y = NULL,
      colour = NULL
    ) +
    facet_grid(metric ~ .)

下の図に示すようにしきい値の値によって各指標は大きく変動する。
サンプリングのみの場合とそれ以外では F 値(f_meas)において逆の傾向が出ている。
しきい値として 0.15 を採用するとキャリブレーションを適用した予測値の F 値(f_meas)が極大となる。


まとめ


  • ダウンサンプリングにより学習時間が大幅(今回は約 1/10)に削減される
  • ダウンサンプリングにより正例寄りにバイアスが発生する
  • キャリブレーションによりダウンサンプリングによるバイアスは(ある程度)解消される
  • ダウンサンプリングにより Log Loss は悪化する
  • キャリブレーションによりダウンサンプリングによる Log Loss の悪化は改善可能
  • 少なくとも今回のデータではしきい値の調整は必須
  • しきい値の調整によりキャリブレーション適用後の各指標をサンプリングなしと同程度まで改善可能

しきい値: 0.15
metric original downsampled calibrated
accuracy 0.977 0.801 0.984
precision 0.813 0.318 0.898
recall 0.976 0.999 0.939
f_meas 0.887 0.482 0.918
mn_log_loss 0.046 0.122 0.065

2020年5月26日火曜日

ベルヌーイ分布と事後分布

以前に読んだ 社会科学のためのベイズ統計モデリング がとても勉強になったので学んだ事をまとめておく。
内容は下記の通り。
  • 3 章 - ベルヌーイ分布に従う確率変数 X におけるパラメータ $\theta$ の事後分布と予測分布
  • 8 章 - 藤井七段(棋士)の勝率についての実データを用いた分析


以下では確率変数 X およびベルヌーイ分布のパラメータ $\theta$ が下記に従うものとして話を進める。
\begin{eqnarray} \label{bernoulli} X &\sim& Bernoulli(\theta) \\ \label{beta} \theta &\sim& Beta(a, b) \end{eqnarray}

1 理論の話


紙とペンで計算を行う事により各種概念を定義および導出していく。

1.1 事後分布


事後分布 $p(\theta | x^{n} )$ についてまずベイズの公式により下記が成立する。 \begin{eqnarray} p(\theta | x^{n}) &=& \frac{ p(x^{n} | \theta) \varphi(\theta) }{ \int p(x^{n} | \theta) \varphi(\theta) d\theta } \nonumber \end{eqnarray} ここで \[ Z_{n} \stackrel{\mathrm{def}}{=} \int_{0}^{1} p(x^{n} | \theta) \varphi(\theta) d\theta \] とおくと \begin{eqnarray} p(\theta | x^{n}) &=& \frac{ p(x^{n} | \theta) \varphi(\theta) }{ Z_{n} } \nonumber \\ &=& \frac{ 1 }{ Z_{n} } \prod_{j = 1}^{n} p(x_{j} | \theta) \cdot \varphi(\theta) ~~~ (\because (\ref{bernoulli}) および X は独立試行) \nonumber \end{eqnarray} となり、このとき ($\ref{bernoulli}$) ($\ref{beta}$) により \begin{eqnarray} p(x | \theta) &=& \theta^{x} (1 - \theta)^{1 - x} & ~~~ \because (\ref{bernoulli}) \nonumber \\ \varphi(\theta) &=& \frac{ 1 }{ B(a, b) } \theta^{ a - 1 } (1 - \theta)^{ b - 1 } & ~~~ \because (\ref{beta}) \nonumber \end{eqnarray} となる事から \begin{eqnarray} p(\theta | x^{n} ) &=& \frac{ 1 }{ Z_{n} } \prod_{j} \theta^{ x_{j} } (1 - \theta)^{ 1 - x_{j} } \cdot \frac{ \theta^{ a - 1 } (1 - \theta)^{ b - 1 } }{ B(a, b) } \nonumber \\ &=& \frac{ 1 }{ Z_{n} } \theta^{ \sum x_{j} } (1 - \theta)^{ n - \sum x_{j} } \cdot \frac{ \theta^{ a - 1 } (1 - \theta)^{ b - 1 } }{ B(a, b) } \nonumber \\ &=& \frac{ 1 }{ Z_{n} \cdot B(a, b) } \theta^{ a + \sum x_{j} - 1 } \cdot (1 - \theta)^{ n + b - \sum x_{j} - 1 } \nonumber \\ \end{eqnarray} が成立する。

ここで $p(\theta | x^{n})$ が確率分布 ($\therefore \int_{0}^{1} p(\theta | x^{n}) d\theta = 1 $) であり $ Z_{n} \cdot B(a, b) $ がその規格化定数である事から \begin{eqnarray} Z_{n} \cdot B(a, b) &=& \int_{0}^{1} \theta^{ a + \sum x_{j} - 1 } (1 - \theta)^{ n + b - \sum x_{j} - 1 } d\theta \nonumber \\ \nonumber \\ &=& B(a + \sum_{j=1}^{n} x_{j}, n + b - \sum_{j=1}^{n} x_{j}) ~~~ (\because ベータ関数の定義) \nonumber \end{eqnarray} となり、よって \begin{eqnarray} p(\theta | x^{n}) &=& \frac{ \theta^{ a + \sum x_{j} - 1} (1 - \theta)^{ n + b - \sum x_{j} - 1 } }{ B(a + \sum x_{j}, n + b - \sum x_{j}) } \nonumber \\ \nonumber \\ &=& Beta(\theta | a + \sum_{j=1}^{n} x_{j}, n + b - \sum_{j=1}^{n} x_{j}). ~~~ (\because ベータ分布の定義) \nonumber \end{eqnarray}
\begin{eqnarray} \label{post_theta} \therefore p(\theta | x^{n}) = Beta(\theta | a + \sum_{j=1}^{n} x_{j}, n + b - \sum_{j=1}^{n} x_{j}). \end{eqnarray}

1.1.1 MAP


($\ref{post_theta}$) より事後分布 $p(\theta | x^{n})$ がベータ分布に従う事から以下ではベータ分布の極値を求める事により事後分布 $p(\theta | x^{n})$ の点推定値の 1 つである MAP(Maximum A Posteriori: 最大事後確率) を導出する。
\begin{eqnarray} \frac{d}{d\theta} Beta(\theta | \alpha, \beta) &=& \frac{d}{d\theta} \frac{1}{B(\alpha, \beta)} \theta^{\alpha-1} \cdot (1-\theta)^{\beta-2} \nonumber \\ &=& \frac{1}{B(\alpha, \beta)} \{ (\alpha-1) \theta^{\alpha-2} \cdot (1 - \theta)^{\beta-1} + \theta^{\alpha-1} \cdot (\beta-1)(1-\theta)^{\beta-2} (-1) \} \nonumber \\ &=& \frac{1}{B(\alpha, \beta)} \theta^{\alpha-2} \cdot (1-\theta)^{\beta-2} \{ (\alpha-1) \cdot (1-\theta) - \theta \cdot (\beta-1) \} \nonumber \\ &=& \frac{1}{B(\alpha, \beta)} \theta^{\alpha-2} \cdot (1-\theta)^{\beta-2} \{ (\alpha-1) - (\alpha + \beta - 2) \theta \} \nonumber \end{eqnarray} により \begin{eqnarray} \frac{d}{d\theta} Beta(\theta | \alpha, \beta) = 0 \nonumber \\ \Leftrightarrow \theta = \frac{\alpha-1}{\alpha+\beta-2} . \nonumber \end{eqnarray} 上記の成立および事後分布 ($\ref{post_theta}$) で $\alpha = a + \sum x_{j}, \beta = n + b - \sum x_{j}$ と読み替える事により \begin{eqnarray} \label{MAP} \therefore \hat{\theta}_{MAP} = \frac{a + \sum x_{j} - 1}{n + a + b - 2}. \end{eqnarray}

1.1.2 MLE


ここでは上記で考えた事後分布とは別に ($\ref{bernoulli}$) に従って尤度関数 $L(\theta)$ を考える事により MLE(Maximum Likelihood Estimator: 最尤推定量) の実現値 $\hat{\theta}_{MLE}$ を導出する。 \begin{eqnarray} L(\theta) &=& p(x^{n} | \theta) \nonumber \\ &=& \prod_{j=1}^{n} \theta^{x_{j}} (1 - \theta)^{1 - x_{j}} \nonumber \\ &=& \theta^{\sum x_{j}} \cdot (1 - \theta)^{n - \sum x_{j}} \nonumber \end{eqnarray} により対数尤度関数 $log L(\theta)$ を $\theta$ で微分して下記を得る。 \begin{eqnarray} \frac{d}{d\theta} log L(\theta) &=& \frac{d}{d\theta} \{ (\sum_{j=1}^{n} x_{j}) \cdot log \theta + (n - \sum_{j=1}^{n} x_{j}) \cdot log(1 - \theta) \} \nonumber \\ &=& \frac{\sum x_{j}}{\theta} - \frac{n - \sum x_{j}}{1 - \theta}. \nonumber \end{eqnarray} よって \begin{eqnarray} &&\frac{d}{d\theta} log L(\theta) = 0 \nonumber \\ \nonumber \\ &\Leftrightarrow& \frac{\sum x_{j}}{\theta} = \frac{n - \sum x_{j}}{1 - \theta} \nonumber \\ &\Leftrightarrow& \theta = \frac{1}{n} \sum_{j=1}^{n} x_{j}. \nonumber \end{eqnarray} \begin{eqnarray} \label{MLE} \therefore \hat{\theta}_{MLE} = \frac{1}{n} \sum_{j=1}^{n} x_{j}. \end{eqnarray}

1.1.3 無情報事前分布における MAP と MLE の一致


MAP ($\ref{MAP}$) における事前分布として無情報事前分布を適用する事を考える。
具体的には ($\ref{beta}$) における $a = b = 1$ の適用であり、$Beta(\theta | 1, 1)$ は定義域 $[0, 1]$ における一様分布である。
このとき下記が成立し、無情報事前分布における MAP($\ref{MAP}$) と MLE($\ref{MLE}$) の一致というよく知られた結果が導出される。 \begin{eqnarray} \hat{\theta}_{MAP} &=& \frac{1 + \sum x_{j} - 1}{n + 1 + 1 - 2} \nonumber \\ &=& \frac{1}{n} \sum_{j=1}^{n} x_{j} \nonumber \\ &=& \hat{\theta}_{MLE}. \nonumber \end{eqnarray}

1.2 予測分布


事後分布 $p(\theta | x^{n})$ による確率モデル $p(x|\theta)$ の期待値を予測分布(predictive distribution) $p^{\ast}(x)$ と定義する。 \begin{eqnarray} p^{*}(x) \stackrel{\mathrm{def}}{=} E_{ p(\theta | x^{n}) }[ p(x | \theta)]. \nonumber \end{eqnarray} ここで ($\ref{bernoulli}$) および事後分布 ($\ref{post_theta}$) により予測分布 $p^{\ast}(x)$ は下記で表される。 \begin{eqnarray} p^{\ast}(x) &=& E_{ p(\theta | x^{n}) }[ p(x | \theta)] \nonumber \\ &=& \int_{0}^{1} p(x | \theta) \cdot p(\theta | x^{n}) d\theta \nonumber \\ &=& \int_{0}^{1} Bernoulli(x | \theta) \cdot Beta(a + \sum x_{j}, b + b - \sum x_{j}) d\theta \nonumber \\ &=& \int_{0}^{1} \theta^{x} (1 - \theta)^{ 1 - x } \cdot \frac{ \theta^{ \alpha - 1 } (1 - \theta)^{ \beta - 1 } }{ B(\alpha, \beta) } d\theta \nonumber \\ \label{pred_dist} &=& \frac{ B(\alpha + x, \beta - x + 1) }{ B(\alpha, \beta) }. \end{eqnarray} ただし上記において $\alpha \stackrel{\mathrm{def}}{=} a + \sum x_{j}, \beta \stackrel{\mathrm{def}}{=} n + b - \sum x_{j}$ により $\alpha$ と $\beta$ を定義するものとする。

1.2.1 EAP


予測分布 ($\ref{pred_dist}$) において特に $x=1$ とおくと \begin{eqnarray} p^{*}(x=1) &=& \int_{0}^{1} \theta \cdot \frac{ \theta^{ \alpha - 1 } (1 - \theta)^{ \beta - 1 } }{ B(\alpha, \beta) } d\theta \nonumber \\ &=& \int_{0}^{1} \theta \cdot p(\theta | x^{n}) d\theta \nonumber \\ &=& E_{ p(\theta | x^{n}) }[\theta]. \nonumber \end{eqnarray}
ここで事後分布 $p(\theta | x^{n})$ における $\theta$ の期待値(平均) $E_{ p(\theta | x^{n}) }[\theta]$ を EAP(Expectd A Posterior estimate) 推定値と呼び、下記が成立する。 \begin{eqnarray} \hat{\theta}_{EAP} &\stackrel{\mathrm{def}}{=}& E_{ p(\theta | x^{n}) }[\theta] \nonumber \\ &=& p^{*}(x=1) \nonumber \\ \label{EAP_incomplete} &=& \frac{ B(\alpha + 1, \beta) }{ B(\alpha, \beta) }. \end{eqnarray} ここでベータ関数の性質より \begin{eqnarray} B(\alpha + 1, \beta) &=& \int_{0}^{1} x^{ \alpha } (1 - x)^{ \beta - 1 } dx \nonumber \\ &=& [ x^{\alpha} \cdot \frac{1}{\beta} (1 - x)^{\beta} (-1) ]_{0}^{1} + \frac{\alpha}{\beta} \int_{0}^{1} x^{\alpha-1} (1 - x)^{\beta} dx \nonumber \\ &=& \frac{\alpha}{\beta} \int_{0}^{1} x^{\alpha-1} (1-x)^{\beta-1} (1-x)^{1} dx \nonumber \\ &=& \frac{\alpha}{\beta} ( \int_{0}^{1} x^{\alpha-1} (1-x)^{\beta-1} dx - \int_{0}^{1} x^{\alpha}(1-x)^{\beta-1} dx ) \nonumber \\ &=& \frac{\alpha}{\beta} ( B(\alpha, \beta) - B(\alpha + 1, \beta) ). \nonumber \end{eqnarray} 右辺の $B(\alpha + 1, \beta)$ を移項して \begin{eqnarray} (1 + \frac{\alpha}{\beta}) B(\alpha + 1, \beta) = \frac{\alpha}{\beta} B(\alpha, \beta). \nonumber \end{eqnarray} よって \begin{eqnarray} \label{beta_them} \therefore B(\alpha + 1, \beta) = \frac{\alpha}{\alpha + \beta} B(\alpha, \beta). \end{eqnarray} 上記 ($\ref{beta_them}$) を ($\ref{EAP_incomplete}$) に適用する事で下記を得る。 \begin{eqnarray} \hat{\theta}_{EAP} &=& \frac{ B(\alpha + 1, \beta) }{ B(\alpha, \beta) } \nonumber \\ &=& \frac{1}{B(\alpha, \beta)} \cdot \frac{\alpha}{\alpha + \beta} B(\alpha, \beta) \nonumber \\ &=& \frac{ \alpha }{ \alpha + \beta } \nonumber \\ &=& \frac{ a + \sum x_{j} }{ a + b + n }. \nonumber \end{eqnarray}
\begin{eqnarray} \label{EAP} \therefore \hat{\theta}_{EAP} = \frac{ a + \sum x_{j} }{ a + b + n }. \end{eqnarray}

1.3 まとめ


\begin{eqnarray} X &\sim& Bernoulli(\theta) \nonumber \\ \theta &\sim& Beta(a, b) \nonumber \end{eqnarray} の下で下記が成立。 \begin{eqnarray} p(\theta | x^{n}) &=& Beta(a + \sum_{j=1}^{n} x_{j}, n + b - \sum_{j=1}^{n} x_{j}) \nonumber \\ \hat{\theta}_{MAP} &=& \frac{a + \sum x_{j} - 1}{n + a + b - 2} \nonumber \\ \hat{\theta}_{EAP} &=& \frac{ a + \sum x_{j} }{ a + b + n } \nonumber \\ \hat{\theta}_{MLE} &=& \frac{1}{n} \sum_{j=1}^{n} x_{j} \nonumber \\ p^{\ast}(x) &=& E_{ p(\theta | x^{n}) }[ p(x | \theta)] \nonumber \end{eqnarray}

2 実データで試す


8 章に記載されている将棋の藤井七段の戦績データを用いて勝率を推定していく。

2.1 データ

生データ及びこの後に記載する R のコードは fujii_game_results@github を参照
※スクレイピング用のコードも上記にあり

ネット上のデータ から 2016年〜2019年 の対戦成績を取得し [対戦日 / 先行or後攻 / 勝敗] の 3 項目を csv にまとめている。
date first_or_second result
2016-12-24
2017-01-26
2017-02-09

2.2 勝率の推定


事前分布として Beta 分布を仮定し、($\ref{MAP}$) ($\ref{MLE}$) ($\ref{EAP}$) により各種推定値の推移を可視化する。

2.2.1 事前分布


事前分布として $Beta(\theta | 2, 2)$ を仮定する。
library(tidyverse)

# 事前分布
a <- 2
b <- 2
tibble(theta = seq(0, 1, 0.01), density = dbeta(theta, a, b)) %>%
  ggplot(aes(theta, density)) +
    geom_line()

事前分布


2.2.2 分析データの生成


事前に算出した MAP/EAP/MLE により勝率を推定したデータを作成。
df.results <- readr::read_csv(
  file = "https://raw.githubusercontent.com/you1025/fujii_game_results/master/data/fujii.csv",
  col_types = cols(
    date = col_date(format = ""),
    first_or_second = col_character(),
    result = col_character()
  )
) %>%

  dplyr::mutate(
    # 対戦番号の付与
    game = dplyr::row_number(),

    # 先攻フラグ
    flg_first = (first_or_second == "先"),

    # 勝利フラグ
    flg_win = (result == "○"),

    # 通算勝利数
    cumsum_win = cumsum(flg_win),

    # 推定値
    MLE = cumsum_win / game,
    EAP = (a + cumsum_win) / (a + b + game),
    MAP = (a + cumsum_win - 1) / (a + b + game - 2)
  ) %>%

  # 対戦前のレコードを追加
  dplyr::bind_rows(tibble(game = 0, cumsum_win = 0, EAP = a / (a + b))) %>%
  dplyr::arrange(game) %>%

  # 事後分布の 2.5%, 97.5% タイルを算出
  dplyr::mutate(
    q.025 = qbeta(p = 0.025, shape1 = a + cumsum_win, shape2 = b + game - cumsum_win),
    q.975 = qbeta(p = 0.975, shape1 = a + cumsum_win, shape2 = b + game - cumsum_win)
  ) %>%

  dplyr::select(
    game,
    flg_first,
    flg_win,
    cumsum_win,
    MLE,
    EAP,
    MAP,
    q.025,
    q.975
  )
game flg_first flg_win cumsum_win MLE EAP MAP q.025 q.975
0 NA NA 0 NA 0.500 NA 0.094 0.905
1 F T 1 1.000 0.600 0.666 0.194 0.932
2 T T 2 1.000 0.666 0.750 0.283 0.947


2.2.3 勝率の推移


# 勝率の推移
df.results %>%
  ggplot(aes(game)) +
    geom_line(aes(y = MLE), linetype = 2, alpha = 1/2) +
    geom_line(aes(y = EAP), linetype = 1, colour = "tomato") +
    geom_line(aes(y = MAP), linetype = 1, colour = "blue", alpha = 1/3) +
    geom_ribbon(aes(ymin = q.025, ymax = q.975), alpha = 1/7) +
    scale_y_continuous(limits = c(0, 1), labels = scales::percent) +
    labs(
      x = "Games",
      y = NULL
    )

勝率の推移


  • 対戦数の少ない段階では MLE(黒色点線) の勝率が 100% と過度にデータを反映した値になってしまっている事がわかる
  • EAP(赤色) が最も低い値となっており MAP(青色) が EAP と MLE の間に挟まれるような値となっている
  • 対戦が進むにつれ 2.5%〜97.5% タイルの幅が狭くなり確信が高まっていく様子が見て取れる

2.2.4 事後分布の変遷


# 対戦ごとの事後分布の一覧
df.results %>%

  dplyr::mutate(
    # 対戦が進む度に事後分布を推定する
    data = map2(game, cumsum_win, function(game, cumsum_win) {
      tibble(
        x = seq(0, 1, 0.001),
        d = dbeta(x, shape1 = a + cumsum_win, b + game - cumsum_win)
      )
    })
  ) %>%
  tidyr::unnest(data) %>%

  ggplot(aes(x, d)) +
    geom_line(aes(group = game, colour = game), alpha = 1/3) +
    scale_colour_gradient(low = "blue", high = "tomato") +
    labs(
      x = "勝率",
      y = NULL
    ) +
    scale_x_continuous(labels = scales::percent) +
    theme_gray(base_family = "Osaka")

事後分布の推移



  • 対戦が進む(赤色が強くなる)ごとに 80% 付近にピークが集まり、高さが増していく事から確信が高まっている様子が見て取れる


参考

2020年1月24日金曜日

変数間の連関をグラフで可視化する

iris を用いてカテゴリデータどうしの連関の強さをφ係数で表現して可視化する事を考える。

使用する主なパッケージの一覧


データの作成


各連続量をカテゴリ値に変換していく。変換のルールは下記。
  • low: 0%〜33% タイル
  • mid: 33%〜66% タイル
  • high: 66%〜100% タイル
また、共起を定義するための同一グループを表すための項目として id 列を追加している事に注意。
library(tidyverse)
library(widyr)

df.iris.cat <- tibble::as_tibble(iris) %>%

  dplyr::mutate(
    # 各レコードを特定できる一意な番号を割り振る
    id = row_number(),

    # 連続量をそれぞれカテゴリ化
    sepal_length = cut(
      Sepal.Length,
      breaks = quantile(Sepal.Length, probs = seq(0, 1, length.out = 4)),
      labels = c("low", "mid", "high"),
      include.lowest = T
    ),
    sepal_width = cut(
      Sepal.Width,
      breaks = quantile(Sepal.Width, probs = seq(0, 1, length.out = 4)),
      labels = c("low", "mid", "high"),
      include.lowest = T
    ),
    petal_length = cut(
      Petal.Length,
      breaks = quantile(Petal.Length, probs = seq(0, 1, length.out = 4)),
      labels = c("low", "mid", "high"),
      include.lowest = T
    ),
    petal_width = cut(
      Petal.Width,
      breaks = quantile(Petal.Width, probs = seq(0, 1, length.out = 4)),
      labels = c("low", "mid", "high"),
      include.lowest = T
    )
  ) %>%

  dplyr::select(
    id,
    species = Species,
    sepal_length,
    sepal_width,
    petal_length,
    petal_width
  )

df.iris.cat
id species sepal_length sepal_width petal_length petal_width
1 setosa low high low low
2 setosa low mid low low
3 setosa low mid low low
4 setosa low mid low low
5 setosa low high low low


データの変換


可視化に向けてデータを変換していく。 ここで使用していない pivot_wider と合わせて pivot_longer はとても便利。
※旧 tidyr::spread/tidyr::gather
df.iris.cor <- df.iris.cat %>%

  # long-format に変換
  tidyr::pivot_longer(species:petal_width, names_to = "feature", values_to = "category") %>%

  # 変数名とカテゴリ値(low, mid, high)を結合
  tidyr::unite(col = "category", feature, category, sep = "_") %>%

  # 各変数間のφ係数を算出
  # upper = False と指定する事で (item1, item2) の対称な組の片方を除外(下三角行列をイメージすると良いかも)
  # ex. (setosa, sepal_length_low) と (sepal_length_low, setosa)
  widyr::pairwise_cor(category, id, upper = F) %>%

  # 同一変数どうしのレコードを排除
  # ex. Species 同士でもφ係数の定義により -0.5 と算出されてしまう
  dplyr::filter(
    # item1 と item2 それぞれの prefix が異なる場合のみを対象
    stringr::str_extract(item1, pattern = "^(species|(sepal|petal)_(length|width))")
      != stringr::str_extract(item2, pattern = "^(species|(sepal|petal)_(length|width))")
  ) %>%

  dplyr::mutate(
    item1 = stringr::str_remove(item1, "^species_"),
    item2 = stringr::str_remove(item2, "^species_")
  )

df.iris.cor
item1 item2 correlation
setosa sepal_length_low 0.8221449
setosa sepal_width_high 0.5837769
sepal_length_low sepal_width_high 0.4056025
setosa petal_length_low 1.0000000
sepal_length_low petal_length_low 0.8221449


可視化


ggraph を用いて可視化してみる
df.iris.cor %>%

  # 小さい係数を除去
  # 閾値の 0.4 はいくつか試して適当に決めた
  dplyr::filter(abs(correlation) > 0.4) %>%

  # tibble をグラフオブジェクトに変換して可視化
  igraph::graph_from_data_frame() %>%
  ggraph::ggraph(layout = "fr") +

    # 辺に関する設定
    ggraph::geom_edge_link(
      aes(
        # 相関の強い関係性は濃く・太く
        edge_alpha = abs(correlation),
        edge_width = abs(correlation),

        # 相関の正/負で辺の色を分ける
        color = factor(correlation > 0)
      ),
      show.legend = F
    ) +
    ggraph::scale_edge_width(range = c(0.35, 1)) +

    # 点に関する設定
    ggraph::geom_node_point(
      aes(
        # Species 由来かどうかで色とサイズを分ける
        colour = as.factor(stringr::str_detect(name, pattern = "^(setosa|versicolor|virginica)")),
        size = ifelse(stringr::str_detect(name, pattern = "^(setosa|versicolor|virginica)"), 7, 1)
      ),
      show.legend = F
    ) +
    ggplot2::scale_size_area() +

    # 点ごとにカテゴリ値を表示
    ggraph::geom_node_text(aes(label = name), vjust = -1, hjust = 0.5, check_overlap = T) +

    ggraph::theme_graph()

相関の正負およびによって色を変えており、正の相関である青色の線に注目すると Virginica/Setosa/Versicolor ごとにクラスタが生成されている事が分かる。

上記から読み取れる内容を列挙してみる。
  • Virginica の特徴として Petal.Length, Petal.Width が共に大きい
  • Setosa の特徴として Petal.Length, Petal.Width, Sepal.Length が共に小さい。また Sepal.Width Sepal.Length が大きい事とも若干の関連がある
  • Versicolor の特徴として Petal.Length, Petal.Width が共に大きくも小さくもない中間的な値である
  • Petal.Length と Petal.Width の間には正の相関がある
など。


まとめ


連続量であっても適当にカテゴリ化して連関を可視化する事である程度は変数間の関係性を把握する事が可能。 連続量どうしであれば PairPlot の劣化版でしかないが、カテゴリデータとの関係性も測る事が出来る点は良いかも。
あと、widyr では連関の指標として相互情報量を算出する事も可能なのでそれを用いた連関の可視化も面白いかもしれない。

参考

2019年6月22日土曜日

Tidymodels による機械学習モデル構築

俺様コードによる tidymodels を用いた機械学習モデル構築のサンプルを記載する。
※基礎的な集計の段階は終了しているという前提で話を進めていく

使用したパッケージの一覧



データの分割(訓練/テスト)


データを訓練用とテスト用に分割する。
strata 引数に目的変数を指定する事で目的変数の分布を変えずにデータを分割する事が可能。
library(tidyverse)
library(tidymodels)

# 元データを訓練用とテスト用に分割
# strata を指定する事でクラスの分布を保持したまま分割
lst.splitted <- rsample::initial_split(iris, prop = 0.75, strata = "Species") %>% {
  list(
    train = rsample::analysis(.),
    test  = rsample::assessment(.)
  )
}

クロスバリデーション用データの分割


クロスバリデーション用にデータを分割する。
strata に目的変数を指定しているのは前述した rsample::initial_split と同じ理由。
# クロスバリデーション用の分割を定義
df.cv <- rsample::vfold_cv(
  lst.splitted$train,
  v = 5,
  strata = "Species"
)

前処理の定義


事前に実施した基礎集計の結果やドメイン知識を元にデータの前処理を行う。
いわゆる特徴量エンジニアリングを行う箇所。

※ここで定義している処理はサンプルとして適当に行っているものであり、実施によって精度は下がっていると思われるw
# 前処理レシピの定義
recipe <- recipes::recipe(Species ~ ., lst.splitted$train) %>%

  # Sepal.Width を削除
  recipes::step_rm(Sepal.Width) %>%

  # Sepal.Length を対数変換
  recipes::step_log(Sepal.Length) %>%

  # 説明変数を基準化
  recipes::step_center(all_predictors()) %>%
  recipes::step_scale(all_predictors())

モデルの定義


今回は RandomForest でモデルを定義する。
各ハイパーパラメータに parsnip::varying を指定する事で後から複数の値を設定可能。今回はグリッドサーチでパラメータを指定する(後述)。

parsnip::set_engine では指定した engine パッケージ特有のパラメータをセットする事ができる。
下記の例では ranger::ranger 関数の num.threads パラメータ(並列処理に用いるコア数)を指定している。
# モデルの定義
model <- parsnip::rand_forest(
  mode = "classification",
  mtry = parsnip::varying(),
  min_n = parsnip::varying(),
  trees = parsnip::varying()
) %>%
  parsnip::set_engine(engine = "ranger", num.threads = 4)

グリッドサーチの定義


3 x 3 x 3 = 27 パターンのハイパーパラメータの組み合わせを定義。
今回はグリッドサーチを行っているが dials::grid_random を用いてランダムサーチを行う事も可能。
# グリッドサーチ用の組み合わせパターンを定義
df.grid.params <- dials::grid_regular(
  dials::mtry  %>% dials::range_set(c(1, 3)),
  dials::min_n %>% dials::range_set(c(2, 6)),
  dials::trees %>% dials::range_set(c(500, 1500)),
  levels = 3
)

モデルの学習と評価


処理の構造としては下記の 2 重ループとなっている。
  1. ハイパーパラメータ一覧のループ
  2. クロスバリデーションによる分割のループ
上記 2 重ループの内部で下記の各ステップを実施。
  1. モデルの学習
  2. 学習済モデルによる予測
  3. モデルの評価
上記ループが終了したら 1 段目のループ(ハイパラ一覧のループ)の内部にて下記を実施。
  • CV 毎の評価スコアを平均してハイパーパラメータ毎の評価スコアを算出

上記によって得られる結果を評価スコアでソートする事で最も性能の良いハイパーパラメータを特定する。
df.results <-

  # ハイパーパラメータをモデルに適用
  # ※2020.05 時点で下記のコードは動かない
  merge(df.grid.params, model) %>%
  # 下記のコードに改修(2020.05.19)
  purrr::pmap(df.grid.params, set_args, object = model) %>%

  # ハイパーパラメータの組み合わせごとにループ
  purrr::map(function(model.applied) {

    # クロスバリデーションの分割ごとにループ
    purrr::map(df.cv$splits, model = model.applied, function(df.split, model) {

      # 前処理済データの作成
      df.train <- recipe %>%
        recipes::prep() %>%
        recipes::bake(rsample::analysis(df.split))
      df.test <- recipe %>%
        recipes::prep() %>%
        recipes::bake(rsample::assessment(df.split))

      model %>%

        # モデルの学習
        {
          model <- (.)

          parsnip::fit(model, Species ~ ., df.train)
        } %>%

        # 学習済モデルによる予測
        {
          fit <- (.)

          list(
            train = predict(fit, df.train, type = "class")[[1]],
            test  = predict(fit, df.test,  type = "class")[[1]]
          )
        } %>%

        # 評価
        {
          lst.predicted <- (.)

          # 評価指標の一覧を定義
          metrics <- yardstick::metric_set(
            yardstick::accuracy,
            yardstick::precision,
            yardstick::recall,
            yardstick::f_meas
          )

          # train データでモデルを評価
          df.result.train <- df.train %>%
            dplyr::mutate(
              predicted = lst.predicted$train
            ) %>%
            metrics(truth = Species, estimate = predicted) %>%
            dplyr::select(-.estimator) %>%
            dplyr::mutate(
              .metric = stringr::str_c("train", .metric, sep = "_")
            ) %>%
            tidyr::spread(key = .metric, value = .estimate)

          # test データでモデルを評価
          df.result.test <- df.test %>%
            dplyr::mutate(
              predicted = lst.predicted$test
            ) %>%
            metrics(truth = Species, estimate = predicted) %>%
            dplyr::select(-.estimator) %>%
            dplyr::mutate(
              .metric = stringr::str_c("test", .metric, sep = "_")
            ) %>%
            tidyr::spread(key = .metric, value = .estimate)

          dplyr::bind_cols(
            df.result.train,
            df.result.test
          )
        }
    }) %>%

      # CV 分割全体の平均値を評価スコアとする
      purrr::reduce(dplyr::bind_rows) %>%
      dplyr::summarise_all(mean)
  }) %>%

  # 評価結果とパラメータを結合
  purrr::reduce(dplyr::bind_rows) %>%
  dplyr::bind_cols(df.grid.params) %>%

  # 評価スコアの順にソート(昇順)
  dplyr::arrange(
    desc(test_accuracy)
  ) %>%

  dplyr::select(
    mtry,
    min_n,
    trees,

    train_accuracy,
    train_precision,
    train_recall,
    train_f_meas,

    test_accuracy,
    test_precision,
    test_recall,
    test_f_meas
  )

上記で得られる df.results のサンプル。
mtry min_n trees train_accuracy train_precision train_recall train_f_meas test_accuracy test_precision test_recall test_f_meas
2 2 500 1.000 1.000 1.000 1.000 0.946 0.949 0.946 0.946
2 4 500 0.991 0.991 0.991 0.991 0.946 0.949 0.946 0.946
3 4 500 0.987 0.987 0.987 0.987 0.946 0.949 0.946 0.946
2 6 500 0.985 0.985 0.985 0.985 0.946 0.949 0.946 0.946
3 6 500 0.982 0.983 0.982 0.982 0.946 0.949 0.946 0.946


ベストモデルの構築


最も性能の良い(=評価スコア最大)モデルを構築する。
この段階では訓練データの全体を用いて学習を行う事に注意。
# 訓練データに前処理を適用
df.train.baked <- recipe %>%
  recipes::prep() %>%
  recipes::bake(lst.splitted$train)

# 最も性能の良いハイパーパラメータを用いたモデルを構築
best_model <- update(
  model,
  mtry  = df.results[1,]$mtry,
  min_n = df.results[1,]$min_n,
  trees = df.results[1,]$trees
) %>%

  # 訓練データ全体を用いてモデルの学習を行う
  parsnip::fit(Species ~ ., df.train.baked)

テストデータによる検証


テストデータを用いた評価を行い、構築したモデルの汎化性能を検証する。
# テストデータに前処理を適用
df.test.baked <- recipe %>%
  recipes::prep() %>%
  recipes::bake(lst.splitted$test)  

# 汎化性能を検証
df.test.baked %>%

  # ベストモデルを用いて予測
  dplyr::mutate(
    predicted = predict(best_model, df.test.baked)[[1]]
  ) %>%

  # 精度(Accuracy)を算出
  yardstick::accuracy(Species, predicted)

まとめ


必要になる度に前に書いたコードを思い出したりコピペしたりでノウハウをまとめられていなかったので、今回は良い機会になった。
機械学習周りは scikit-learn 一択かなと思ってた頃もあったけど tidymodels がいい感じなのでぜひ使う人が増えてくれると嬉し。
他の人がどんな感じでやってるのかも知りたいところ。

2019年6月11日火曜日

時系列のクロスバリデーション

tidymodels に含まれる rsample パッケージの rolling_origin という関数が便利そうだったので試してみた内容を記載してみる。表題の通り時系列データに対するクロスバリデーションを行う。

この関数を知る切っ掛けになったのがこちら。ありがとうございます。

https://blog.hoxo-m.com/entry/2019/06/08/220307

rolling_origin 関数


時系列データのクロスバリデーションに関する考え方はこちらのサイト Rでのナウなデータ分割のやり方: rsampleパッケージによる交差検証 や書籍 前処理大全 に任せるとして、rolling_origin 関数の skip 引数の意味が分かりづらかったので少し試してみる事にする。

まず、今回使用するデータを準備。
"S4248SM144NCEN" というカラム名が何かは知らないけどデータの名称からして恐らく飲酒量とかその辺りだと思われるのでとりあえず "amount" という項目名に変更しておく。

library(tidyverse)
library(tidymodels)

df.drinks <- rsample::drinks %>%
  dplyr::rename(amount = S4248SM144NCEN)

# date amount
1 1992-01-01 3,459
2 1992-02-01 3,458
3 1992-03-01 4,002

このデータは 1992/01/01 から 2017/09/01 まで続く月次データであり、全部で 309 件のレコードが存在する。
このデータを下記の要件にて分割する事を考えてみる。
  • 学習期間: 24ヶ月
  • 検証期間: 12ヶ月
  • スライド: 1ヶ月

上記を実現したのがこちら
df.rolling <- rsample::rolling_origin(
  df.drinks,
  initial = 24,
  assess = 12,
  skip = 0,
  cumulative = F
)

df.rolling
# Rolling origin forecast resampling 
# A tibble: 274 x 2
   splits          id      
                
 1 <split [24/12]> Slice001
 2 <split [24/12]> Slice002
 3 <split [24/12]> Slice003
 4 <split [24/12]> Slice004
 5 <split [24/12]> Slice005
 6 <split [24/12]> Slice006
 7 <split [24/12]> Slice007
 8 <split [24/12]> Slice008
 9 <split [24/12]> Slice009
10 <split [24/12]> Slice010
# … with 264 more rows

学習データの 1 番目を取り出してみた。確かに 1992/1/1 を含む 24 ヶ月間のデータとなっている。
df.rolling$splits[[1]] %>%
  rsample::analysis()

# A tibble: 24 x 2
   date       amount
         
 1 1992-01-01   3459
 2 1992-02-01   3458
 3 1992-03-01   4002
 4 1992-04-01   4564
 5 1992-05-01   4221
 6 1992-06-01   4529
 7 1992-07-01   4466
 8 1992-08-01   4137
 9 1992-09-01   4126
10 1992-10-01   4259
# … with 14 more rows

検証用データの 1 番目を取り出してみる。こちらも想定通り 1994 年の 12 ヶ月分のデータとなっている。
df.rolling$splits[[1]] %>%
  rsample::assessment()

# A tibble: 12 x 2
   date       amount
         
 1 1994-01-01   3075
 2 1994-02-01   3377
 3 1994-03-01   4443
 4 1994-04-01   4261
 5 1994-05-01   4460
 6 1994-06-01   4985
 7 1994-07-01   4324
 8 1994-08-01   4719
 9 1994-09-01   4374
10 1994-10-01   4248
11 1994-11-01   4784
12 1994-12-01   4971

2 番目の学習用/検証用のデータも取り出してみる。想定通り 1 ヶ月スライドしたデータとなっている。
df.rolling$splits[[2]] %>%
  rsample::analysis()
# A tibble: 24 x 2
   date       amount
         
 1 1992-02-01   3458
 2 1992-03-01   4002
 3 1992-04-01   4564
 4 1992-05-01   4221
 5 1992-06-01   4529
 6 1992-07-01   4466
 7 1992-08-01   4137
 8 1992-09-01   4126
 9 1992-10-01   4259
10 1992-11-01   4240
# … with 14 more rows

df.rolling$splits[[2]] %>%
  rsample::assessment()
# A tibble: 12 x 2
   date       amount
         
 1 1994-02-01   3377
 2 1994-03-01   4443
 3 1994-04-01   4261
 4 1994-05-01   4460
 5 1994-06-01   4985
 6 1994-07-01   4324
 7 1994-08-01   4719
 8 1994-09-01   4374
 9 1994-10-01   4248
10 1994-11-01   4784
11 1994-12-01   4971
12 1995-01-01   3370

ここで最初に疑問に思ったのが rolling_origin 関数の skip 引数の仕様である。例えば下記のようなデータ仕様を考えてみる。
  • 学習期間: 24ヶ月
  • 検証期間: 12ヶ月
  • スライド: 12ヶ月
つまり 1 年おきにデータを学習/予測(ex. 年の始めに 2 年分のデータを使って直近 1 年間の予測を行う)するようなケースとなる。 この時に直感的には下記のようなコードを想定していた。
df.rolling2 <- rsample::rolling_origin(
  df.drinks,
  initial = 24,
  assess = 12,
  skip = 12,
  cumulative = F
)

12 ヶ月データをスキップするので skip 引数に 12 を指定しているが、これだと下記のように意図した通りにはならない。 2 番目の学習データが 1993/1/1 ではなく 1993/2/1 からのスタートになってしまっており、実際には 13 ヶ月のスキップとなってしまっている。
df.rolling2$splits[[2]] %>%
  rsample::analysis()
# A tibble: 24 x 2
   date       amount
         
 1 1993-02-01   3261
 2 1993-03-01   4160
 3 1993-04-01   4377
 4 1993-05-01   4307
 5 1993-06-01   4696
 6 1993-07-01   4458
 7 1993-08-01   4457
 8 1993-09-01   4364
 9 1993-10-01   4236
10 1993-11-01   4500
# … with 14 more rows

これはそもそも rolling_origin 関数における skip 引数のデフォルト値が 0 であり、この指定によって 1 ヶ月おきのスキップを意図している事による。 つまりそこから更にずらす幅を追加したい時に skip 引数を使いなさいという事なのだろうなと。

これはドキュメントをちゃんと読むとそれっぽい事が記載されていた。ちゃんと読みなさいよと
When skip = 0, the resampling data sets will increment by one position.
あとから考えてみると skip = 0 の指定でスライドしないのであれば無限ループになってしまう orz

なので、上記の仕様であれば下記のコードが正解となる。
df.rolling3 <- rsample::rolling_origin(
  df.drinks,
  initial = 24,
  assess = 12,
  skip = 11, # 11 = 12 - 1
  cumulative = F
)

df.rolling3$splits[[2]] %>%
  rsample::analysis()
# A tibble: 24 x 2
   date       amount
         
 1 1993-01-01   3031
 2 1993-02-01   3261
 3 1993-03-01   4160
 4 1993-04-01   4377
 5 1993-05-01   4307
 6 1993-06-01   4696
 7 1993-07-01   4458
 8 1993-08-01   4457
 9 1993-09-01   4364
10 1993-10-01   4236
# … with 14 more rows

最初から複数月のスライドを意図したデータを作ろうとしていたのが失敗で、スライド幅 1 のケースから順番に試していれば混乱しなかったんだろうなと。難しい

クロスバリデーションによるモデル比較

せっかく rolling_origin の使い方がだいたい分かったので、これを用いて時系列モデルの比較を行ってみる事にする。

まず比較対象となるモデルの一覧を list で作成する。実際には当該リストに含まれるのは学習済みモデルを返す関数である事に注意。
lst.models <- list(
  # ローカルレベルモデル + 確定的季節成分
  d1_ss = function(df.data) {
    library(KFAS)
    model <- SSModel(
      amount ~
        SSMtrend(degree = 1, Q = NA) +
        SSMseasonal(period = 12, Q = 0),
      data = df.data,
      H = NA
    )
    fitSSM(
      model,
      inits = c(1, 1),
      method = "BFGS"
    )
  },

  # ローカルレベルモデル + 確率的季節成分
  d1_sv = function(df.data) {
    library(KFAS)
    model <- SSModel(
      amount ~
        SSMtrend(degree = 1, Q = NA) +
        SSMseasonal(period = 12, Q = NA),
      data = df.data,
      H = NA
    )
    fitSSM(
      model,
      inits = c(1, 1, 1),
      method = "BFGS"
    )
  },

  # ローカル線形トレンドモデル + 確定的季節成分
  d2_ss = function(df.data) {
    library(KFAS)
    model <- SSModel(
      amount ~
        SSMtrend(degree = 2, Q = list(matrix(NA), matrix(NA))) +
        SSMseasonal(period = 12, Q = 0),
      data = df.data,
      H = NA
    )
    fitSSM(
      model,
      inits = c(1, 1, 1),
      method = "BFGS"
    )
  },

  # ローカル線形トレンドモデル + 確率的季節成分
  d2_sv = function(df.data) {
    library(KFAS)
    model <- SSModel(
      amount ~
        SSMtrend(degree = 2, Q = list(matrix(NA), matrix(NA))) +
        SSMseasonal(period = 12, Q = NA),
      data = df.data,
      H = NA
    )
    fitSSM(
      model,
      inits = c(1, 1, 1, 1),
      method = "BFGS"
    )
  }
)

リストの要素を指定してデータを渡せば KFAS による学習済みモデルが返却される。
lst.models$d1_ss(df.drinks)

### ↓以下結果 ###

$optim.out
$optim.out$par
[1]  9.355875 12.207159

$optim.out$value
[1] 2290.374

$optim.out$counts
function gradient 
      29       10 

$optim.out$convergence
[1] 0

$optim.out$message
NULL


$model
Call:
SSModel(formula = amount ~ SSMtrend(degree = 1, Q = NA) + SSMseasonal(period = 12, 
    Q = 0), data = df.data, H = NA)

State space model object of class SSModel

Dimensions:
[1] Number of time points: 309
[1] Number of time series: 1
[1] Number of disturbances: 2
[1] Number of states: 12
Names of the states:
 [1]  level        sea_dummy1   sea_dummy2   sea_dummy3   sea_dummy4   sea_dummy5   sea_dummy6   sea_dummy7   sea_dummy8 
[10]  sea_dummy9   sea_dummy10  sea_dummy11
Distributions of the time series:
[1]  gaussian

Object is a valid object of class SSModel.

こんな感じに予測も可能。
predict(lst.models$d1_ss(df.drinks)$model)

Time Series:
Start = 1 
End = 309 
Frequency = 1 
             fit
  [1,]  2719.484
  [2,]  3162.725
  [3,]  4213.908
  [4,]  4128.718
  [5,]  4758.920 ...

上記を用いて下記のようにモデル毎に評価結果を返す関数を作って実験を行う。
評価指標は CV データ毎に算出した RMSE の平均を用いる事にする。
# h: 予測する範囲 ex. h=1 で 1 期先予測
# skip: スキップ数
rmse_per_model_cv <- function(h, skip = 0) {
  # 時系列 CV の作成
  df.rolling <- rsample::rolling_origin(df.drinks, initial = 24, assess = h,  cumulative = F, skip = skip)

  # 各モデルの適用
  purrr::map(lst.models, function(model) {

    # クロスバリデーション
    purrr::map_dbl(df.rolling$splits, function(split) {
      df.train <- rsample::analysis(split)
      df.test  <- rsample::assessment(split)

      # モデルの学習
      fit <- model(df.train)

      # 学習済みモデルによる予測と RMSE の算出
      # columns: date, amount, predicted
      df.test %>%
        dplyr::mutate(
          predicted = predict(fit$model, n.ahead = h)[, "fit"] %>% as.numeric()
        ) %>%
        yardstick::rmse(amount, predicted) %>%
        .$.estimate
    })
  })
}

モデル毎に 24 ヶ月の学習データを用いて 12 期先までの予測を行う試行を 23 回行い、23 回分の RMSE の平均値をモデルの評価値とする。
# 予測結果の一覧を取得
rmses <- rmse_per_model_cv(h = 12, skip = 11)

# モデル毎に RMSE の平均値を算出
purrr::map_dbl(rmses, ~ mean(.))

   d1_ss    d1_sv    d2_ss    d2_sv 
561.9146 472.5309 442.3523 449.9628

今回のデータだとモデル d2_ss(ローカル線形トレンド+確定的季節成分) の予測精度が最も高いという結果になった。