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年9月3日木曜日

JuMP でビンパッキング問題おためし

Julia の数理最適化パッケージ JuMP を用いて混合整数線型計画問題を試してみる。
JuMP で通常の線形計画問題を解くのは JuMP で線型計画問題おためし を参照。


1. 環境の準備


今回用いるパッケージは下記。
  • JuMP
  • CBC (MILP:混合整数計画問題 のソルバ)
※環境構築に関しては JuMP で線型計画問題おためし を参照
※各 Solver に関しては Getting Solvers を参照


2. ビンパッキング問題


ビンパッキング問題の詳細は Wikipedia を参照してもらうとして、 今回は下記の状況を扱う。

  • それぞれ重さの異なる荷物を大きめのビンに詰める事を考える
  • 使用するビンの数を最も少なくする格納方法を考えたい
  • 荷物 10 個
    • 重さ 2 kg: 4 個
    • 重さ 3 kg: 3 個
    • 重さ 4 kg: 2 個
    • 重さ 5 kg: 1 個
  • ビンごとの最大容量 5 kg
※ちなみに 「ビン」 は 「瓶」 ではなく 「Bin(容器)」 の事


3. 定式化


荷物の数を n とすると必要なビンの数は最大でも n 個となるので、使われないビンの存在も許容してビンの数を(多めに) n と設定する。
このとき各ビンに荷物が 1 つでも格納されたかどうかを判定する変数 y[1〜n] を、各成分に 0 か 1 が格納されるベクトルで表現する。

\begin{eqnarray} \forall j \in \{ 1, 2, \dots, n \}, \ \ y_{j} \in \{ 0, 1 \} \nonumber . \end{eqnarray} 次に行列 $X = \{ x_{i, j} \}$ を下記で定義する。
  • 行(i): 各荷物(1〜n)に対応
  • 列(j): 各ビン(1〜n)に対応
  • 各 $x_{i, j}$ には 0 か 1 が格納される

最後にベクトル $s$ 及びスカラ $B$ を下記で定義する。
  • $s$: 各荷物の重さ
    • 今回の場合は $s = [ 2, 2, 2, 2, 3, 3, 3, 4, 4, 5 ]$
  • $B$: 各ビンごとの最大容量
    • 今回の場合は $B = 5$

3.1 目的関数


出来るだけビンの数を少なくしたいので、各 $y_{j}$ の和を最小とする事を考える。 \begin{eqnarray} minimize \sum_{j} y_{j} \end{eqnarray}

3.2 制約条件(1)


まず、各荷物 1〜n がそれぞれ 1 つのビンに必ず格納される事を表現する。
\begin{eqnarray} \forall i, \ \ \sum_{j} x_{i, j} = 1 \nonumber \end{eqnarray}
この条件は n 個の成分が全て 1 であるベクトル $e (\forall i, \ e_{i} = 1)$ を用いて下記のように書き換え可能。 \begin{eqnarray} X \cdot e = 1 \end{eqnarray}

3.3 制約条件(2)


次に、各ビンに格納された荷物の重さの合計が $B$ を以下となる事を表現する。 \begin{eqnarray} \forall j, \ \ \sum_{i} s_{i} \cdot x_{i, j} \leqq B \cdot y_{j} \nonumber \end{eqnarray}
この条件は行列の転置を用いて下記のように書き換え可能。 \begin{eqnarray} {}^t\!X \cdot s \leqq B \cdot y \end{eqnarray}

3.4 制約条件(3)


最後に、行列 $X$ およびベクトル $y$ の各成分に関する条件を表現する。
\begin{eqnarray} \forall i, j, \ \ x_{i, j} \in \{ 0, 1 \} \\ \forall j, \ \ y_{j} \in \{ 0, 1 \} \end{eqnarray}

3.5 まとめ


上記 (1)〜(5) を用いて下記のようにまとめられる。
\begin{eqnarray} minimize & & \sum_{j} y_{j} \nonumber \\ subject \ to & & X \cdot e = 1 \nonumber \\ & & {}^t\!X \cdot s \leqq B \cdot y \nonumber \\ & & \forall i, j, \ \ x_{i, j} \in \{ 0, 1 \} \nonumber \\ & & \forall j, \ \ y_{j} \in \{ 0, 1 \} \nonumber \end{eqnarray}

4. JuMP


JuMP を用いて最適解を求める。
MILP(混合整数線型問題) を扱う事のできる CBC をソルバーに用いる。
using JuMP
using Cbc
using LinearAlgebra

# 荷物の数 => 最大のビンの数
n = 10

# ビンごとの最大容量
B = 5

# 荷物ごとの重さ
s = [2, 2, 2, 2, 3, 3, 3, 4, 4, 5]


# CBC を用いたモデルを作成
model = Model(Cbc.Optimizer)

# 変数を追加
# X: 荷物とビンの関係
# y: ビン使用の有無
# binary = true で 2 値(0 or 1)変数である事を指定
@variable(model, X[i=1:n, j=1:n], binary = true) # (4)
@variable(model, y[1:n], binary = true)          # (5)

# 目的関数を最小化
@objective(model, Min, sum(y))                   # (1)

# 制約条件を追加
@constraint(model, X * ones(n) .== 1)            # (2)
@constraint(model, transpose(X) * s .<= B .* y)  # (3)

# 最適化の実行
optimize!(model)


### 実行結果 ###

Welcome to the CBC MILP Solver 
Version: 2.10.3 
Build Date: May 23 2020 

command line - Cbc_C_Interface -solve -quit (default strategy 1)
Continuous objective value is 6 - 0.00 seconds
〜中略〜

Result - Optimal solution found

Objective value:                7.00000000
Enumerated nodes:               0
Total iterations:               83
Time (CPU seconds):             0.37
Time (Wallclock seconds):       0.12

Total time (CPU seconds):       0.37   (Wallclock seconds):       0.12

4.1 実行結果


正常に終了し、各荷物の各ビンへの格納が正しく行われている事を確認できる。
println("TerminationStatus: ", termination_status(model))
# => TerminationStatus: OPTIMAL

println("PrimalStatus: ", primal_status(model))
# => PrimalStatus: FEASIBLE_POINT

println("ObjectiveValue: ", objective_value(model))
# => ObjectiveValue: 7.0

# 荷物の格納されたビンの情報のみを表示
for i = 1:n, j = 1:n
    x = value(X[i,j])
    if x > 0
        println("X[", i, "," , j, "]: ", x)
    end
end
X[1,2]: 1.0
X[2,6]: 1.0
X[3,7]: 1.0
X[4,9]: 1.0
X[5,7]: 1.0
X[6,9]: 1.0
X[7,2]: 1.0
X[8,10]: 1.0
X[9,8]: 1.0
X[10,4]: 1.0

ビンと荷物の対応
  • ビン 2:
    • 荷物1(2kg)
    • 荷物7(3kg)
  • ビン 4:
    • 荷物10(5kg)
  • ビン 6:
    • 荷物2(2kg)
  • ビン 7:
    • 荷物3(2kg)
    • 荷物5(3kg)
  • ビン 8:
    • 荷物9(4kg)
  • ビン 9:
    • 荷物4(2kg)
    • 荷物6(3kg)
  • ビン 10:
    • 荷物8(4kg)


参考

2020年8月29日土曜日

JuMP で線型計画問題おためし

Julia の数理最適化パッケージ JuMP を用いて線型計画問題のお試しをしてみる。
サンプル問題は ここ の 『1.1 学生宿舎の朝食』 から拝借した。

1. 環境の準備


今回用いる下記パッケージをインストールする。
※プロジェクトの作成および Jupyter を用いる場合は Jupyter でプロジェクトを指定して Julia カーネル追加 を参照 ※各 Solver に関しては Getting Solvers を参照

今回は TestOR プロジェクトを作成済みとして以下を進めていく
# REPL を起動
$ julia

# パッケージ管理モードに移行
julia> ]

# 作成済の TestOR プロジェクトへ移行
(@v1.5) pkg> activate TestOR

# 必要なパッケージを追加
(TestOR) pkg> add JuMP
(TestOR) pkg> add Clp
(TestOR) pkg> add Cbc

2. 線型計画問題として解く


2.1 モデルの作成


変数として下記 2 つを考える。
  • 牛乳 1 単位(1/2 カップ): m
  • シリアル 1 単位(1/4 袋): s
using JuMP
using Clp

# CLP を用いたモデルを作成
model = Model(Clp.Optimizer)

# 変数を追加
# m: 牛乳
# s: シリアル
@variable(model, 0 <= m)
@variable(model, 0 <= s)

# 制約条件を追加
@constraint(model, 9 <= 3 * m + 2 * s)
@constraint(model, 1/3 <= m/15 + (2/15) * s)
@constraint(model, 1/4 <= m/6)
@constraint(model, m/3 <= s)
@constraint(model, s <= 2m)

# 目的関数を最小化
@objective(model, Min, 50 * m + 65 * s)

2.2 モデルの確認


model オブジェクトを確認すると目的関数および制約条件の一覧が表示される(Jupyter の場合)。



2.3 最適化の実施


optimize! コマンドで最適化を実施。
optimize!(model)

最適化の結果を参照するには下記関数の実行が必要。
  • termination_status (終了ステータス)
  • primal_status (Solution Status)
  • objective_value (目的関数の値)
  • value(variable) (変数の値)
※termination_status の結果については Termination statuses を、primal_status の結果については Solution Statuses を参照
println("TerminationStatus: ", termination_status(model))
# => OPTIMAL

println("PrimalStatus: ", primal_status(model))
# => FEASIBLE_POINT

println("ObjectiveValue: ", objective_value(model))
# => 197.5

println("milk: ", value(m))
# => 2.0

println("cerial: ", value(s))
# => 1.5
正しく回答が得られた。

3. 行列を用いて問題を表現する


上記 2. で記載した各制約条件は、行列 $A$ およびベクトル $b, x$ を用いて $b <= Mx $ と表現可能。
行列 $A$ およびベクトル $b$ を適切に表現する事により下記のように表現可能となる。
using JuMP
using Clp
using LinearAlgebra

# 行列 A
A = [
    3 2
    1/15 2/15
    1/6 0
    -1/3 1
    2 -1
]

# ベクトル b
b = [
    9
    1/3
    1/4
    0
    0
]

# CLP を用いたモデルを作成
model = Model(Clp.Optimizer)

# 変数を追加
# x[1]: 牛乳
# x[2]: シリアル
@variable(model, x[1:2] >= 0)

# 制約条件を追加
@constraint(model, b .<= A * x)

# 目的関数を最小化
# dot 関数で内積を計算: 50 * x[1] + 65 * x[2]
@objective(model, Min, LinearAlgebra.dot([50 65], x))

# 最適化の実施
optimize!(model)

結果を表示。
println("TerminationStatus: ", termination_status(model))
# => OPTIMAL

println("PrimalStatus: ", primal_status(model))
# => FEASIBLE_POINT

println("ObjectiveValue: ", objective_value(model))
# => 197.5

println("milk: ", value(x[1]))
# => 2.0

println("cerial: ", value(x[2]))
# => 1.5


4. 混合整数計画問題として解く


上記では牛乳およびシリアルの使用単位に制限が存在しなかったが、下記では牛乳 1/2 カップおよびシリアル 1/4 袋ごとにしか使用できないという前提で問題を考えてみる。
ソルバーとして CBC を指定し、変数を整数に制限する事により整数計画問題(MILP=Mixed-Integer Linear Programming)として問題を解く。
using JuMP
using Cbc # Clp から変更
using LinearAlgebra

# 行列 A
A = [
    3 2
    1/15 2/15
    1/6 0
    -1/3 1
    2 -1
]

# ベクトル b
b = [
    9
    1/3
    1/4
    0
    0
]

# CBC を用いたモデルを作成
# CLP から変更している事に注意
model = Model(Cbc.Optimizer)

# 変数を整数として追加
# x[1]: 牛乳
# x[2]: シリアル
@variable(model, x[i=1:2] >= 0, integer = true) # integer=true で整数を指定("Int" でも可)

# 制約条件を追加
@constraint(model, b .<= A * x)

# 目的関数を最小化
# dot 関数で内積を計算: 50 * x[1] + 65 * x[2]
@objective(model, Min, LinearAlgebra.dot([50 65], x))

# 最適化の実施
optimize!(model)

結果を表示
println("TerminationStatus: ", termination_status(model))
# => OPTIMAL

println("PrimalStatus: ", primal_status(model))
# => FEASIBLE_POINT

println("ObjectiveValue: ", objective_value(model))
# => 215.0

println("milk: ", value(x[1]))
# => 3.0

println("cerial: ", value(x[2]))
# => 1.0
牛乳とシリアルの最適解がそれぞれ整数値として得られている。

参考
  • 講義資料: https://www.cs.tsukuba.ac.jp/~takahito/ucourse/sys_math/part1.pdf

2020年8月27日木曜日

Jupyter でプロジェクトを指定して Julia カーネル追加

以前に試した内容が頭から抜けてたので備忘録として残しておく。

Julia でプロジェクトを作成し、そのプロジェクトを指定して jupyter に kernel を追加する。

下記の手順で進めていく。
  1. Julia で新規 project を作成する
  2. 適当なパッケージを追加する
  3. Jupyter に新規 kernel を追加する
  4. 追加した kernel を試す

1. Project の作成


適当なディレクトリに移動して下記を実施。
# REPL を起動
$ julia

# "]" コマンドでパッケージモードに移行
julia> ]

# "Test" プロジェクトを作成
(@v1.5) pkg> generate Test
 Generating  project Test:
    Test/Project.toml
    Test/src/Test.jl
REPL を抜けてカレントディレクトリを確認すると Test ディレクトリが作成されており、下記のファイルおよびディレクトリが格納されている事が確認できる。
  • Project.toml
  • src

2. パッケージの追加


テスト用に下記パッケージを追加する。
  • IJulia - Jupyter 用パッケージ(必須)
  • Plots
# REPL を起動
$ julia

# "]" コマンドでパッケージモードに移行
julia> ]

# Test プロジェクト環境へ移行
(@v1.5) pkg> activate Test
 Activating environment at `~/prog/julia/Test/Project.toml`

# Test プロジェクト環境で IJulia パッケージ(Jupyter用)をインストール
(Test) pkg> add IJulia

# Plots パッケージ(テスト実行用)をインストール
# GR も同時にインストールされる模様
(Test) pkg> add Plots
下記にてインストール済みパッケージの一覧を確認できる。
(Test) pkg> status
Project Test v0.1.0
Status `~/prog/julia/Test/Project.toml`
  [28b8d3ca] GR v0.51.0
  [7073ff75] IJulia v1.21.3
  [91a5bcdd] Plots v1.6.0

3. Jupyter に Kernel を追加


上記で作成した Test プロジェクト環境を Jupyter 上の Kernel として追加する。
# REPL を起動
$ julia

# "]" コマンドでパッケージモードに移行
julia> ]

# Test プロジェクト環境へ移行
(@v1.5) pkg> activate Test
 Activating environment at `~/prog/julia/Test/Project.toml`

# BackSpace キーでパッケージモードから抜ける
# Test 環境は継続する事に注意
(Test) pkg> (BackSpace キーを押下)

# IJulia.installkernel コマンドで新規 kernel を追加
# "--project" オプションでプロジェクト(Test)を指定している事に注意
julia> using IJulia
julia> IJulia.installkernel("JuliaTest", "--project=/各々の環境でフルパス指定/Test")
[ Info: Installing JuliaTest kernelspec in /各々の環境に依存/Jupyter/kernels/juliatest-1.5
"/各々の環境に依存/Jupyter/kernels/juliatest-1.5"

3.1 設定ファイルでプロジェクトを指定


上記の IJulia.installkernel における "--project" 指定は各 kernel 用の設定ファイルを編集する事でも代用可能となる。
下記にその方法(Mac のみ)を記載する。

Julia のバージョンとプロジェクト名は各々の環境に合わせて変更する。
下記の場合だと "argv" の 5 個目の引数が当該オプションとなるのでこれを編集もしくは追加する事でプロジェクトを指定可能。
$ cat ~/Library/Jupyter/kernels/juliatest-1.5/kernel.json
{
  "display_name": "JuliaTest 1.5.1",
  "argv": [
    "/Applications/Julia-1.5.app/Contents/Resources/julia/bin/julia",
    "-i",
    "--startup-file=yes",
    "--color=yes",
    "--project=/各々の環境でフルパス指定/Test",
    "/各々の環境に依存/.julia/packages/IJulia/tOM8L/src/kernel.jl",
    "{connection_file}"
  ],
  "language": "julia",
  "env": {},
  "interrupt_mode": "signal"
}

3.2 kernel 一覧の確認


下記コマンドで kernel 一覧に今回作成した新規 kernel(juliatest-1.5) が追加されている事が確認できる。
$ jupyter kernelspec list
Available kernels:
  ...その他の kernel...
  juliatest-1.5  /ホームディレクトリのパス/Library/Jupyter/kernels/juliatest-1.5

4. 追加した kernel を試す


Jupyter を起動して kernel の一覧に今回追加した kernel が存在する事を確認。



"JuliaTest 1.5.1" kernel を指定したファイルを開いて下記コードを試す事により、以前に追加したパッケージ Plots を呼び出し可能である事が確認できる。
using Plots
gr()

x = randn(10, 3)
plot(x)


4.1 追加したカーネルの削除


今回のお試し用の環境を削除するにはコンソール上から対象カーネルを指定して下記を実行する。
確認のメッセージが表示されるので "y" を入力してリターンキーを押下するとカーネルの削除が実施される。
$ jupyter kernelspec remove juliatest-1.5
Kernel specs to remove:
  juliatest-1.5  /ホームディレクトリのパス/Library/Jupyter/kernels/juliatest-1.5
Remove 1 kernel specs [y/N]:y # y を入力してリターンキーを押下

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 では連関の指標として相互情報量を算出する事も可能なのでそれを用いた連関の可視化も面白いかもしれない。

参考