備忘ログ

チラシの裏的備忘録&メモ

RでExcelのLOOKUP関数のように対応表として値を別のデータフレームを参照して列を追加する方法について

RでExcelのLOOKUP関数のように対応表として値をデータフレームでもっているの場合に、対応している値を参照して列を追加する方法について考えてみる。小ネタ。

次のQiitaの記事を読んで思った。

qiita.com

いろいろな手法が考えられるが、{dplyr}を使うのであればleft_join()が比較的シンプルに書けると思う。

NAは0で埋めたいようなので{tidyr}replace_na()を使うことにする。ついでに、表示される列名をSP_noにするために、rename()しておく。

具体的には次の通り。

library(dplyr)
## 
##  次のパッケージを付け加えます: 'dplyr'

##  以下のオブジェクトは 'package:stats' からマスクされています:
## 
##     filter, lag

##  以下のオブジェクトは 'package:base' からマスクされています:
## 
##     intersect, setdiff, setequal, union
library(tidyr)

df <- data.frame(
  SP_name = c("ブナ", "スギ", "ヒノキ", "", "スギ", "コナラ", "", "ヒノキ", "ブナ", "コナラ"),
  SP_no = NA
)
SP_table <- data.frame(
  name = c("スギ", "ヒノキ", "カラマツ", "ブナ", "コナラ", "ミズナラ"),
  no = 1:6
)

df <- left_join(df[1], SP_table, by = join_by(SP_name == name)) |> 
  replace_na(list(no = 0)) |> 
  rename(SP_no = no)
df
##    SP_name SP_no
## 1     ブナ     4
## 2     スギ     1
## 3   ヒノキ     2
## 4              0
## 5     スギ     1
## 6   コナラ     5
## 7              0
## 8   ヒノキ     2
## 9     ブナ     4
## 10  コナラ     5

もとの記事内で紹介されている手法でも特段に問題はないが、せっかくデータフレームで対応表を持っているのに何度も$でデータフレームの中身をベクトルで呼び出したりしているので、「あ、対照表まちがってたわー。他のデータフレームだったわー」とか「指定する列まちがってたわー」などあったときに書き直さなければならない箇所がやや多い気がする。

Rの`{ggplot2}`でグラフを描画するときの、欠測値等のデータの一部が欠けた場合の積み上げ面グラフについて考えてみる

Rの{ggplot2}でグラフを描画するときの、欠測値等のデータの一部が欠けた場合の積み上げ面グラフについて考えてみる。

そもそも歯抜けの場合には面グラフが微妙な気がするという話は一旦おいておく。

サンプルデータは次の通り。

set.seed(123)
df <- data.frame(x = 1:12,
                 y_1 = c(rnorm(5, 4), rep(NA, 2), rnorm(5, 5)),
                 y_2 = c(rnorm(5, 4), rep(NA, 2), rnorm(5, 5)),
                 y_3 = c(rnorm(5, 4), rep(NA, 2), rnorm(5, 5)))

この積み上げ棒グラフを書いてみる。

# ggplot2で扱いやすいように縦持ちにしておく
df <- tidyr::pivot_longer(df, cols = dplyr::starts_with("y"),
                          names_to = "y")

library(ggplot2)
df |> 
  ggplot(aes(x = x, y = value, fill = y)) + 
  geom_bar(stat = "identity") +
  scale_x_continuous(breaks = seq(1, 12, 1))
## Warning: Removed 6 rows containing missing values (`position_stack()`).

これを同じようにgeom_area()で書くと欠測値の箇所が埋められて次の観測値までなめらかに繫がるように調整され、次のようになってしまう。

df |> 
  ggplot(aes(x = x, y = value, fill = y)) +
  geom_area(position = "stack") +
  scale_x_continuous(breaks = seq(1, 12, 1))
## Warning: Removed 6 rows containing non-finite values (`stat_align()`).

これはNAをを含む行を削除しても同じことになる。

df |> 
  tidyr::drop_na() |> 
  ggplot(aes(x = x, y = value, fill = y)) +
  geom_area(position = "stack") +
  scale_x_continuous(breaks = seq(1, 12, 1))

解決策としてはgeom_ribbon()を使えばよい。

geom_ribbon()は、面の上側(ymax)、下側(ymin)の値をそれぞれ与えなければならないので先に計算して値としてもっておく。

library(dplyr)
## 
##  次のパッケージを付け加えます: 'dplyr'

##  以下のオブジェクトは 'package:stats' からマスクされています:
## 
##     filter, lag

##  以下のオブジェクトは 'package:base' からマスクされています:
## 
##     intersect, setdiff, setequal, union
# ymaxとyminを計算する
df <- df |> 
  arrange(x, desc(y)) |>
  mutate(ymax = value |> 
           tidyr::replace_na(0) |> 
           cumsum(), 
         ymin = lag(ymax, default = 0),
         ymax = if_else(ymax == ymin, NA, ymax),
         .by = x) 

df |> 
  ggplot(aes(x = x, ymax = ymax, ymin = ymin, fill = y)) +
  geom_ribbon() +
  scale_x_continuous(breaks = seq(1, 12, 1))

みんな一斉に同じ期間データが欠測している場合にはこれで良さそう。

時々バラバラのタイミングで欠測値がある場合を考えてみる。

set.seed(123)
df <- data.frame(x = 1:12,
                 y_1 = c(rnorm(3, 10), rep(NA, 2), rnorm(4, 10), rep(NA, 3)),
                 y_2 = c(rep(NA, 3), rnorm(9, 10)),
                 y_3 = c(rep(NA, 2), rnorm(3, 10), rep(NA, 2), rnorm(5, 10)))

これの積み上げ棒グラフを適当にggplot2で書いてみる。

# ggplot2で扱いやすいように縦持ちにしておく
df <- tidyr::pivot_longer(df, cols = dplyr::starts_with("y"),
                          names_to = "y")

df |> 
  ggplot(aes(x = x, y = value, fill = y)) + 
  geom_bar(stat = "identity") +
  scale_x_continuous(breaks = seq(1, 12, 1))
## Warning: Removed 12 rows containing missing values (`position_stack()`).

これを積み上げ面グラフにするのにgeom_area()を使おうとすると次のようになる。

df |> 
  ggplot(aes(x = x, y = value, fill = y)) +
  geom_area(position = "stack") +
  scale_x_continuous(breaks = seq(1, 12, 1))
## Warning: Removed 12 rows containing non-finite values (`stat_align()`).

先程の例と同じでNAを含む行を除いても同じ結果になる。

先の例と同じように、上側と下側の値を計算してgeome_ribbon()を使うと次のようになる。

# ymaxとyminを計算する
df <- df |> 
  arrange(x, desc(y)) |>
  mutate(ymax = value |> 
           tidyr::replace_na(0) |> 
           cumsum(), 
         ymin = lag(ymax, default = 0),
         ymax = if_else(ymax == ymin, NA, ymax),
         .by = x) 

df |> 
  ggplot(aes(x = x, ymax = ymax, ymin = ymin, fill = y)) +
  geom_ribbon() +
  scale_x_continuous(breaks = seq(1, 12, 1))

たしかに、各xの箇所ででの値はそうなるし、そこからつなげるとそうなるのだけれど、面グラフとしてグラフの中に塗られていないスカスカの箇所があるのは見た目上違和感を感じる。

見た目を整えるという目的であれば暫定的な解決策の一つとしては、NA0で埋めてやると次のように層状にちゃんとなる。

df |> 
  tidyr::replace_na(list(value = 0)) |> 
  ggplot(aes(x = x, y = value, fill = y)) +
  geom_area() +
  scale_x_continuous(breaks = seq(1, 12, 1))

ただ、この場合、NAが0になっているのでじわっと減ったり増えたりしている印象をもたせるので、全体の総量の経過を経時的に見せたい場合以外には良くない気がする(NAが0という意味であれば問題ないが)。

上記の場合、グラフだけだと棒グラフも欠測値なのか0なのかはわかりかねるというのは変わらない。

RStudioのアップデートの有無をPosit社(旧RStudio社)のウェブページ上の情報を参照し確認するRStudio用のアドインを作ってみた

RStudioはアップデートがあると通知してくれるし、上のメニューからHelp > Check for Updatesをクリックすると任意のタイミングでアップデートの有無のチェックができる……ことになっているが、しばしばこれがうまく行かないことがある。

Check for Updatesをしてもアップデートないです~、と言われていてたまたまウェブサイトをみてアップデートがあることに気づくこともある。

最新のRStudio 2023.06.2のリリースノートのFixedにも

Fixed bug preventing Update Available from displaying (#13347)

とある。以前のバージョンでもたまにあった。特に致命的とまでは言えないがちょっと困る。

基本的にはRStudioは新機能の追加やバグフィックスなどを含めて最新版を使い続けたいので、ときどきはアップデートの有無を確認したいが、わざわざPosit社(旧RStudio社)のウェブサイトを確認するのはめんどくさい。

SNSを使って確認するなどいろいろ方法はあると思うが、RStudio上から本当にアップデートがないのか確認したいと思った。ので作った(のはだいぶ前だが特に最近書くことがないのでブログに書いてみた)。

github.com

方法としては、RStudioのダウンロードページにあるバージョンの記載を参照し、その値と現在のRStudioのバージョンの差を比較する関数を作って、それをRStudioのAddinに登録するようなパッケージを作成した。

このウェブページをチェックして新しいバージョンがあるかどうかをチェックする手法は{installr}check.for.updates.R()関数で用いている手法に倣っており、せっかくなので{installr}check.for.updates.R()をラップしてR本体のアップデートの有無のチェックをするAddinも作った。

ついでに、RStudioのリリースノートをウェブブラウザで開くショート的関数も作りAddinに登録するようにした。これもRStudioからワンクリックで飛べたらいいなと思って作った。

いずれも既存のものがあるのかもしれないが見つけられなかった。

使い方はインストールするとRStudioのAddinsに“Checks a newer version of R”、“Checks a newer version of RStudio”、“View NEWS of RStudio”の3つが登録されるので、それぞれをクリックすると、R本体のアップデートの有無のチェック、RStudioのアップデートの有無のチェック、RStudioのリリースノート表示(ウェブブラウザ)を行うようになる。

“Checks a newer version of R”

ダイアグラムのリンクをクリックするとCRANのページに飛ぶ。

“Checks a newer version of RStudio”

ダイアグラムのリンクをクリックするとRStudioのダウンロードページに飛ぶ。

“View NEWS of RStudio”

自分の痒いところに手が届くように。

Rで値の文字と数字を分ける方法のメモ

Rで値の文字と数字を分ける方法のメモ。方法はいろいろありそうだが、とりあえずシンプルに。

例えば、データに30cmや500mlなどの数値と文字列がくっついている場合がある。このとき、これらの値(30cmなど)は文字列として処理されるので処理しにくく、数値と文字列を切り分けたい場合がある。

まずは適当なサンプルデータを作る。特に意味はないけど、今回は{tibble}を使ってデータフレームを作ってみるが普通のデータフレームでもいい。

library(tibble)
d <- tibble(length = c("-20cm", "30cm", "40cm", "0.5m", "0.6m"))

このデータの長さはcmやmの単位がついており、数値を取り出すだけではなく、単位も取り出して、単位がcmのところは1/100して単位をmに統一という欲求も満たしたい。また数値は負の値にも対応したいと考える。

複雑なパターンを想定し始めると結構条件が多くなるので、今回のように数値1つに単位1つのパターンで考えてみる。

{stringr}str_remove_all()を使って任意の文字を条件を指定し数値と単位を切り出すことにする。

今回の場合は数値(小数点、マイナス記号を含む)を[[:digit:]-.]で指定するといい([0-9-.]でもいい)。数値を取り出すときは^をつけてそれらを含まない文字を切り捨て、文字列のみを取り出すときには数値を切り捨てるようにするとうまく取り出せる。

また、数値は取り出したら数値型にするとあとで取り扱いやすい。

あとは、単位にcmを含むものを条件にif_else()をつかって1/100するようにすると期待通りの結果が得られる。

library(dplyr)
## 
##  次のパッケージを付け加えます: 'dplyr'

##  以下のオブジェクトは 'package:stats' からマスクされています:
## 
##     filter, lag

##  以下のオブジェクトは 'package:base' からマスクされています:
## 
##     intersect, setdiff, setequal, union
library(stringr)

d <- d |> 
  mutate(length_num = str_remove_all(length, "[^[:digit:]-.]") |> 
           as.numeric(),
         length_str = str_remove_all(length, "[[:digit:]-.]"), 
         length_m = if_else(length_str == "cm", length_num * 0.01, length_num))
d
## # A tibble: 5 × 4
##   length length_num length_str length_m
##   <chr>       <dbl> <chr>         <dbl>
## 1 -20cm       -20   cm             -0.2
## 2 30cm         30   cm              0.3
## 3 40cm         40   cm              0.4
## 4 0.5m          0.5 m               0.5
## 5 0.6m          0.6 m               0.6

{tidyverse}ファミリーの{dplyr}パッケージと{stringr}パッケージをつかったが、組み込み関数({base})でも実現できる。

文字列の切り出しはgsub()を使う。

d <- data.frame(length = c("-20cm", "30cm", "40cm", "0.5m", "0.6m"))
d <- d |> 
  transform(length_num = gsub(pattern = "[^[:digit:]\\.-]", replacement = "", x = length) |> 
              as.numeric(),
            length_str = gsub(pattern = "[[:digit:]\\.-]", replacement = "", x = length)) |> 
  transform(length_m = ifelse(length_str == "cm", length_num * 0.01, length_num))
d
##   length length_num length_str length_m
## 1  -20cm      -20.0         cm     -0.2
## 2   30cm       30.0         cm      0.3
## 3   40cm       40.0         cm      0.4
## 4   0.5m        0.5          m      0.5
## 5   0.6m        0.6          m      0.6

Rでまとめて無相関検定(相関分析)を行って、その結果を一覧したい

Rでまとめて無相関検定(相関分析)を行って、その結果を一覧したいと思った。

相関係数だけを一括して見たいならcor()という関数もあるが、無相関検定や95%信頼区間なども一緒に確認したいと思った。

cor(iris[1:4])
##              Sepal.Length Sepal.Width Petal.Length Petal.Width
## Sepal.Length    1.0000000  -0.1175698    0.8717538   0.8179411
## Sepal.Width    -0.1175698   1.0000000   -0.4284401  -0.3661259
## Petal.Length    0.8717538  -0.4284401    1.0000000   0.9628654
## Petal.Width     0.8179411  -0.3661259    0.9628654   1.0000000

データフレーム中の複数の列を組み合わせてまとめて無相関検定を行う関数を作る(答えはlist形式で返す)。

multi.cor.test <- function(df, vars, ...){
  if(missing(vars)) vars <- colnames(df)
  vars_length <- length(vars)
  vars_list_x <- vector()
  vars_list_y <- vector()
  n <- vars_length - 1
  
  for(i in vars){
    if(n == 0) break
    vars_list_x <- c(vars_list_x, rep(i, n))
    n <- n - 1
  }
  
  for(i in 1:vars_length){
    vars_list_y <- c(vars_list_y, vars[-c(1:i)])
  }
  
  mapply(function(x, y) cor.test(as.formula(paste("~", x, "+", y)), data = df, ...),
         vars_list_x,
         vars_list_y,
         SIMPLIFY = FALSE)
}

この関数だと、データフレームの指定された列同士(vars)のすべての組み合わせて無相関検定を行い結果をリストに保存する処理となる。

varsが指定されていない場合、データフレームのすべての列を用いて計算する。

このとき、変数にaとbとあったとき、aとaの組み合わせは相関係数1になることが明らかなので、その組み合わせは計算しない。また、aとbの組み合わせとbとaの組み合わせは相関分析においては同義なので、aとbのみの組み合わせだけを計算するようにしている。

たとえばirisデータのSpeciesを除く全ての組み合わせで無相関検定を行う場合は次のようにする。

# Species以外の行名を取り出しておく。
vars <- colnames(iris[1:4])
multi.cor.test(df = iris, vars = vars)
## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Sepal.Width
## t = -1.4403, df = 148, p-value = 0.1519
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.27269325  0.04351158
## sample estimates:
##        cor 
## -0.1175698 
## 
## 
## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Petal.Length
## t = 21.646, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.8270363 0.9055080
## sample estimates:
##       cor 
## 0.8717538 
## 
## 
## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Petal.Width
## t = 17.296, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.7568971 0.8648361
## sample estimates:
##       cor 
## 0.8179411 
## 
## 
## $Sepal.Width
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Width and Petal.Length
## t = -5.7684, df = 148, p-value = 4.513e-08
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.5508771 -0.2879499
## sample estimates:
##        cor 
## -0.4284401 
## 
## 
## $Sepal.Width
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Width and Petal.Width
## t = -4.7865, df = 148, p-value = 4.073e-06
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.4972130 -0.2186966
## sample estimates:
##        cor 
## -0.3661259 
## 
## 
## $Petal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Petal.Length and Petal.Width
## t = 43.387, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.9490525 0.9729853
## sample estimates:
##       cor 
## 0.9628654

## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Sepal.Width
## t = -1.4403, df = 148, p-value = 0.1519
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.27269325  0.04351158
## sample estimates:
##        cor 
## -0.1175698 
## 
## 
## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Petal.Length
## t = 21.646, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.8270363 0.9055080
## sample estimates:
##       cor 
## 0.8717538 
## 
## 
## $Sepal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Length and Petal.Width
## t = 17.296, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.7568971 0.8648361
## sample estimates:
##       cor 
## 0.8179411 
## 
## 
## $Sepal.Width
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Width and Petal.Length
## t = -5.7684, df = 148, p-value = 4.513e-08
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.5508771 -0.2879499
## sample estimates:
##        cor 
## -0.4284401 
## 
## 
## $Sepal.Width
## 
##  Pearson's product-moment correlation
## 
## data:  Sepal.Width and Petal.Width
## t = -4.7865, df = 148, p-value = 4.073e-06
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  -0.4972130 -0.2186966
## sample estimates:
##        cor 
## -0.3661259 
## 
## 
## $Petal.Length
## 
##  Pearson's product-moment correlation
## 
## data:  Petal.Length and Petal.Width
## t = 43.387, df = 148, p-value < 2.2e-16
## alternative hypothesis: true correlation is not equal to 0
## 95 percent confidence interval:
##  0.9490525 0.9729853
## sample estimates:
##       cor 
## 0.9628654

このままだと見た目の量が多くやや見にくいので、統計量や相関係数、p値や95%信頼区間をデータフレーム形式で表示できると嬉しい。

そこで、{broom}パッケージのtidy()関数をつかって、計算された値などをデータフレーム形式(tibble形式)で取り出す。

答えがlist形式なので{purrr}パッケージのmap_df()を使う。{broom}tidy()htestクラスだと何と何の統計結果なのか(print()した時のdataのところ、list上のdata.nameのところ)を取り出さないので、それも取り出すようにすると次にようになる。

multi.cor.test(df = iris[1:4]) |> 
  purrr::map_df(\(x) c(data = x$data.name, broom::tidy(x)))
## # A tibble: 6 × 9
##   data           estimate statistic  p.value parameter conf.low conf.high method
##   <chr>             <dbl>     <dbl>    <dbl>     <int>    <dbl>     <dbl> <chr> 
## 1 Sepal.Length …   -0.118     -1.44 1.52e- 1       148   -0.273    0.0435 Pears…
## 2 Sepal.Length …    0.872     21.6  1.04e-47       148    0.827    0.906  Pears…
## 3 Sepal.Length …    0.818     17.3  2.33e-37       148    0.757    0.865  Pears…
## 4 Sepal.Width a…   -0.428     -5.77 4.51e- 8       148   -0.551   -0.288  Pears…
## 5 Sepal.Width a…   -0.366     -4.79 4.07e- 6       148   -0.497   -0.219  Pears…
## 6 Petal.Length …    0.963     43.4  4.68e-86       148    0.949    0.973  Pears…
## # ℹ 1 more variable: alternative <chr>

2つの変数はandで文章接続されているので次のようにすると変数名で取り出せる。

multi.cor.test(df = iris, vars = vars) |> 
  purrr::map_df(\(x){
    xy_name <- strsplit(x$data.name, " and ")
    c(x = xy_name[[1]][1], y = xy_name[[1]][2], broom::tidy(x))})
## # A tibble: 6 × 10
##   x        y     estimate statistic  p.value parameter conf.low conf.high method
##   <chr>    <chr>    <dbl>     <dbl>    <dbl>     <int>    <dbl>     <dbl> <chr> 
## 1 Sepal.L… Sepa…   -0.118     -1.44 1.52e- 1       148   -0.273    0.0435 Pears…
## 2 Sepal.L… Peta…    0.872     21.6  1.04e-47       148    0.827    0.906  Pears…
## 3 Sepal.L… Peta…    0.818     17.3  2.33e-37       148    0.757    0.865  Pears…
## 4 Sepal.W… Peta…   -0.428     -5.77 4.51e- 8       148   -0.551   -0.288  Pears…
## 5 Sepal.W… Peta…   -0.366     -4.79 4.07e- 6       148   -0.497   -0.219  Pears…
## 6 Petal.L… Peta…    0.963     43.4  4.68e-86       148    0.949    0.973  Pears…
## # ℹ 1 more variable: alternative <chr>

Rでまとめてt検定を行った結果を一覧で確認する方法のメモ

Rでまとめてt検定を行った結果を一覧で確認する方法のメモ。

要旨としては

  • まとめてt検定を行った時の結果をデータフレーム(tibble)形式で表示してくれる{broom}パッケージのtidy()関数をつかって表示する。

というもの。

まとめてt検定を行う方法についてはこちらの方法を使っていく。

www.rpubs.com

こちらの方法を使うと、グループ間での異なる変数について複数のt検定等を行う事ができる。

示されている例にならって、試しにやってみると次のようになる。

data(cancer, package="survival")
# 上記のページに提示されている関数を使う。
multi.tests <- function(fun = t.test, df, vars, group.var, ...) {
    sapply(simplify = FALSE,                                    # sapply(simplify=T) better, elements named
           vars,                                                # loop on vector of outcome variable names
           function(var) {
               formula <- as.formula(paste(var, "~", group.var))# create a formula with outcome and grouping var.
               fun(data = df, formula, ...)                     # perform test with a given fun, default t.test
           }
           )
}
# まとめてt検定を行う。
res.multi.t.tests <-
    multi.tests(fun = t.test,
                df = kidney,
                vars = c("time","age","frail"),
                group.var = "sex",
                var.equal = TRUE)
# 結果を確認する。
res.multi.t.tests
## $time
## 
##  Two Sample t-test
## 
## data:  time by sex
## t = -1.706, df = 74, p-value = 0.09221
## alternative hypothesis: true difference in means between group 1 and group 2 is not equal to 0
## 95 percent confidence interval:
##  -124.551228    9.651228
## sample estimates:
## mean in group 1 mean in group 2 
##           59.30          116.75 
## 
## 
## $age
## 
##  Two Sample t-test
## 
## data:  age by sex
## t = -0.069294, df = 74, p-value = 0.9449
## alternative hypothesis: true difference in means between group 1 and group 2 is not equal to 0
## 95 percent confidence interval:
##  -7.970091  7.434376
## sample estimates:
## mean in group 1 mean in group 2 
##        43.50000        43.76786 
## 
## 
## $frail
## 
##  Two Sample t-test
## 
## data:  frail by sex
## t = 0.95032, df = 74, p-value = 0.345
## alternative hypothesis: true difference in means between group 1 and group 2 is not equal to 0
## 95 percent confidence interval:
##  -0.1872248  0.5286533
## sample estimates:
## mean in group 1 mean in group 2 
##        1.310000        1.139286

結果をt検定のそれぞれの結果がprint()print.htest())で表示した時の状態で表示される。

少量なら別にそんなに気にならないが、画面がスクロールしてしまうので少し見にくい。統計量やp値や95%信頼区間をデータフレーム形式で表示できると嬉しい。

このように計算結果の値をデータフレーム形式(tibble形式)で出力する関数としてとして{broom}tidy()があり、これはt検定などの結果のhtestクラスにも対応している。

そのためたとえば、

ans <- t.test(extra ~ group, data = sleep)
broom::tidy(ans)
## # A tibble: 1 × 10
##   estimate estimate1 estimate2 statistic p.value parameter conf.low conf.high
##      <dbl>     <dbl>     <dbl>     <dbl>   <dbl>     <dbl>    <dbl>     <dbl>
## 1    -1.58      0.75      2.33     -1.86  0.0794      17.8    -3.37     0.205
## # ℹ 2 more variables: method <chr>, alternative <chr>

と一連の計算結果がデータフレーム形式で表示できる、この関数を用いてmulti.test()で計算された結果を表示できると嬉しい。

multi.test()の結果はリスト形式になっているので、purrr::map_df()で取り出していくことにする。{broom}tidy()htestクラスだと何と何の統計結果なのか(print()した時のdataのところ、list上のdata.nameのところ)を取り出さないので、それも取り出すようにすると次にようになる。

あらかじめinstall.packages("purrr")install.packages("broom")で必要なパッケージをインストールしておく必要がある。

res.multi.t.tests |> 
  purrr::map_df(\(x) c(data = x$data.name, broom::tidy(x)))
## # A tibble: 3 × 11
##   data         estimate estimate1 estimate2 statistic p.value parameter conf.low
##   <chr>           <dbl>     <dbl>     <dbl>     <dbl>   <dbl>     <dbl>    <dbl>
## 1 time by sex   -57.4       59.3     117.     -1.71    0.0922        74 -125.   
## 2 age by sex     -0.268     43.5      43.8    -0.0693  0.945         74   -7.97 
## 3 frail by sex    0.171      1.31      1.14    0.950   0.345         74   -0.187
## # ℹ 3 more variables: conf.high <dbl>, method <chr>, alternative <chr>

こうすると複数のt検定をしたときに、統計量やp値や95%信頼区間の上下限も一括で簡単に確認できるようになる。

ロジスティック回帰分析の適合度検定であるHosmer-Lemeshow test(ホスマー・レメショウ検定)のRでの実装について考えてみる-その3。

ロジスティック回帰分析の適合度検定であるHosmer-Lemeshow test(ホスマー・レメショウ検定)のRでの実装について考えてみる-その3。

要旨としては

  • Hosmer-Lemeshow testについて、十分位でサブグループを分割する方法がうまく行かない場合のサブグループの分割方法について、期待確率を小さい順に並べたときに同じ期待確率の値のデータは同じサブグループになるように調整しつつ、各サブグループのデータポイント数がおおよそ等しくなる分け方について考えてみた。
  • 1つ目はデータポイント数が最小のサブグループは隣接するサブグループと統合する、というのをサブグループ数が任意のグループ数になるまで繰り返す処理の場合。
  • 2つ目は隣接するサブグループと統合しサブグループ数が任意の数になるパターンを総当りで計算し、その中でサブグループ数の分散が最も小さい組み合わせを取り出す方法の場合。

Hosmer-Lemeshow testはグループ分けが重要である。

この回帰分析を用いた疫学データの解析 基本編(2)の解説の

代表的なものはモデルが予測する目的変数の値を小さいほうから順に並べ各グループに含まれるデータポイント数が大体同じになるようグループ分けしていく方法である。

という方法についてついて、前回は期待確率の小さい順に並べて、単純に任意のサブグループ数で分割するという単純な礼につついて考えた。

ただ、期待確率が一様に分散していない場合は上記の単純に分割するだけでは、同じ期待確率のものが別のサブグループに含まれることになってしまうので不適切になってしまうことが容易に想像される。

たとえば、Titanicデータで作る次のロジスティック回帰分析のモデルを考えてみる。

data("Titanic")
df <- data.frame(Titanic)
df <- data.frame(Class = rep(df$Class, df$Freq),
                 Sex = rep(df$Sex, df$Freq),
                 Age = rep(df$Age, df$Freq),
                 Survived = rep(df$Survived, df$Freq))
model <- glm(Survived ~ . ,data = df, family = binomial())

このモデルの各期待確率の数は次のようにして取り出すことができる。

expect_logit <- sapply(sort(unique(fitted.values(model))),
                       function(x) sum(fitted.values(model) == x))
names(expect_logit) <- sort(unique(fitted.values(model)))
expect_logit
## 0.103959413496167 0.198719327281383 0.225499724406818 0.251158568967817 
##               462               168               862                48 
## 0.407038204021279 0.417565455179058  0.56612912085157 0.664924908154581 
##               175                11               165                 5 
## 0.736089652810118 0.766053806997806 0.790446281655777 0.885323441946764 
##                93                23                31               144 
## 0.889661176752348 0.957114111822833 
##                13                 1

このように、期待確率は偏って分布している。

ヒストグラムにすると次の通り。

hist(fitted.values(model))

このような場合のサブグループの分割方法について考えてみる。

これを10個のサブグループにするときに、各グループに含まれるデータポイントの数は

sum(expect_logit) / 10
## [1] 220.1

を目指したいが、隣接する期待確率のグループを足して10個のサブグループを作ろうとしても、そのような組み合わせはない。

ということで、もう少し条件を考えてグループ分けできるようにしてみる。

ここからは各サブグループに含まれる個数だけで考えてみる。各サブグループに含むべきデータポイント数さえわかれば、小さい順に並べてその数だけ取り出せばいい。

データポイントの数が最小のサブグループのデータポイントの数が最大になる(ようにしたい)

サブグループに含まれているデータポイント数の最小値が最大になる組み合わせで和を取り、長さが指定されたサブグループ数のベクトルを作るように考える。

vec_g <- function(x, g){
  if(g <= 3) stop("If g is less than 3, it cannot be calculated well.")
  
  ans <- x
  ans_len <- length(ans)
  while (ans_len > g){
    ans_min <- which(ans == min(ans))
    ans_list <- lapply(ans_min, function(ans_min){
      if(ans_min == 1){
        ans_ <- c(ans[ans_min] + ans[ans_min + 1], ans[(ans_min + 2):ans_len])
      }else if(ans_min == ans_len){
        ans_ <- c(ans[1:(ans_min - 2)], ans[ans_min - 1] + ans[ans_min])
      }else{
        if(ans_min == 2){
          ans_pre <- c(ans[ans_min - 1] + ans[ans_min], ans[(ans_min + 1):ans_len])
        }else{
          ans_pre <- c(ans[1:(ans_min - 2)], ans[ans_min - 1] + ans[ans_min], ans[(ans_min + 1):ans_len])
        }
        
        if((ans_min + 2) == ans_len){
          ans_pos <- c(ans[1:(ans_min - 1)], ans[ans_min] + ans[ans_min + 1], ans[ans_len])
        }else if((ans_min + 1) == ans_len){
          ans_pos <- c(ans[1:(ans_min - 1)], ans[ans_min] + ans[ans_min + 1])
        }else{
          ans_pos <- c(ans[1:(ans_min - 1)], ans[ans_min] + ans[ans_min + 1], ans[(ans_min + 2):ans_len])
        }
        
        ans_pre_var <- stats::var(ans_pre)
        ans_pos_var <- stats::var(ans_pos)
        
        if(ans_pre_var < ans_pos_var || is.na(ans_pos_var)) ans <- ans_pre
        else ans_ <- ans_pos
      }
    })
    ans <- unlist(unname(ans_list[which.min(lapply(ans_list, stats::var))]))
    ans_len <- length(ans)
  }
  ans
}

これは、まずベクトル内の最小値を求め、その後、前後の値との和を取り、分散が小さい方を採用するというのを、ベクトルの長さが指定された長さになるまで繰り返すという処理になっている。

ところで分散についてはRで計算される分散は不偏分散になるが、結果に影響はない。

試しに、Titanicデータのモデルの場合で数を確認してみる。

vec_g(unname(expect_logit), 10)
##  [1] 462 168 862  48 175 176  98  23  31 158

結構いい感じにサブグループを分割できるようになっていると思う。

ただし、この方法ではいい感じに計算できないパターンがあり、例えば次のようなモデルを考えてみる。

model_1 <- glm(vs ~ wt + mpg, data = mtcars, family = binomial())

このときの期待確率は次のようになっている。

explect_1 <- sapply(sort(unique(fitted.values(model_1))), function(x) sum(fitted.values(model_1) == x))
explect_1
##  [1] 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1 1
length(explect_1)
## [1] 32

と、1が32個のベクトルになっている。

vec_g(explect_1, 10)
##  [1] 4 4 4 4 4 4 2 2 2 2

この1が32個の場合は、4が2つでのこり8個が3であるのが最も分散が小さく、各サブグループに含まれるデータポイントの数が平均に近づくはず。

次に別の方法を考えてみる。

すべての組み合わせを計算してデータポイントの数の分散が最小のものを選択する

すべてのデータポイントの数が平均に近い組み合わというのは、データポイントの数の分散が最も小さい組み合わとみなせると思う。

隣接するグループ同士で合体させて、任意の長さのベクトルになる組み合わせを総当りで計算し、分散が最も小さい組み合わせを取り出す方法を考えてみる。

vec_n <- function(x, g){
  if(g <= 3) stop("If g is less than 3, it cannot be calculated well.")
  
  vec_add <- function(x){
    lapply(1:(length(x) - 1), function(a){
      if(a == 1) c(x[1] + x[2], x[3:length(x)])
      else if(a + 2 == length(x)) c(x[1:(a - 1)], x[a] + x[a + 1], x[a + 2])
      else if(a + 1 == length(x)) c(x[1:(a - 1)], x[a] + x[a + 1])
      else if(a == 2) c(x[1], x[2] + x[3], x[4:length(x)])
      else c(x[1:(a - 1)], x[a] + x[a + 1], x[(a + 2):length(x)])
    })
  }
  
  ans <- list(x)
  while(length(ans[[1]]) > g){
    ans <- lapply(ans, vec_add)
    ans <- unique(purrr::list_flatten(ans))
  }
  
  ans_n <- sapply(ans, stats::var)
  if(length(ans_n) == 1) ans <- unlist(ans)
  else if(length(min(ans_n)) == 1) ans <- unlist(ans[which.min(ans_n)])
  else{
    ans <- ans[ans_n == min(ans_n)]
    ans_n <- sapply(ans, min)
    ans <- unlist(ans[which.max(ans_n)])
  }
  ans
}

この場合のTitanicデータのモデルの個数を考えてみる。

vec_n(unname(expect_logit), 10)
##  [1] 462 168 862  48 175 176  98  54 144  14

これは先程のサブグループに含まれるデータポイントの数の最小値が最大になるようにした手法と一致する場合もあるが、今回の場合は分散がこちらの方が小さくなっている。

vec_g(unname(expect_logit), 10) |> var()
## [1] 66692.77
vec_n(unname(expect_logit), 10) |> var()
## [1] 66403.21

ただし、この計算は指定された長さにいたるまで計算を続けるのでメモリや計算量が多くなる。

たとえば、mtcarのデータでのモデルの場合、長さ32のベクトルから10のベクトルに至るまでのすべての組み合わせを計算する(一部同じベクトルがでれば削除するが)のでかなり重たく、とりあえず自分の環境では計算が終わらなかった。

もっと効率の良い計算方法があると思うがこれが一番簡単にかけた。

それぞれの組み合わせでHosmer-Lemeshow testしてみる

これらの組み合わせでサブグループをHosmer-Lemeshow testを計算するRの関数をgithubにあげている{infun}パッケージに入れてみたのでそちらで計算してみる。

github.com

  • simpleをTRUEにすると前項の単純に切り分ける手法になる。
  • simpleをFALSEにしてforceをFALSEにするとサブグループに含まれるデータポイント数の最小値が最大になる組み合わせで統計量を計算する。
  • simpleをFALSEにしてforceをTRUEにすると総当りでサブグループに含まれるデータポイント数の分散が最小になる組み合わで統計量を計算する。

となっている。

データポイント数の最小値が最大の組み合わせでサブグループを計算した場合は次のとおりになる。

# データポイント数の最小値が最大の場合
HL_1 <- infun::hosmer_test(model, g = 10, force = FALSE)
HL_1
## 
##  Hosmer and Lemeshow goodness of fit test
## 
## data:  glm(formula = Survived ~ ., family = binomial(), data = df)
## X-squared = 82.909, df = 8, p-value = 1.266e-14
HL_1$observed
##                
## subgroup        y0_obs y1_obs
##   0.104            387     75
##   0.199            154     14
##   0.225            670    192
##   0.251             35     13
##   0.407            118     57
##   0.418 - 0.566     89     87
##   0.665 - 0.736     13     85
##   0.766              3     20
##   0.79              17     14
##   0.885 - 0.957      4    154
HL_1$expected
##                
## subgroup         y0_expect  y1_expect
##   0.104         413.970751  48.029249
##   0.199         134.615153  33.384847
##   0.225         667.619238 194.380762
##   0.251          35.944389  12.055611
##   0.407         103.768314  71.231686
##   0.418 - 0.566  77.995475  98.004525
##   0.665 - 0.736  26.219038  71.780962
##   0.766           5.380762  17.619238
##   0.79            6.496165  24.503835
##   0.885 - 0.957  17.990715 140.009285

総当りでサブグループに含まれているデータポイント数の分散が最小になる組み合わせで統計量を計算すると次のとおりになる。

HL_2 <- infun::hosmer_test(model, g = 10, force = TRUE)
HL_2
## 
##  Hosmer and Lemeshow goodness of fit test
## 
## data:  glm(formula = Survived ~ ., family = binomial(), data = df)
## X-squared = 67.253, df = 8, p-value = 1.727e-11
HL_2$observed
##                
## subgroup        y0_obs y1_obs
##   0.104            387     75
##   0.199            154     14
##   0.225            670    192
##   0.251             35     13
##   0.407            118     57
##   0.418 - 0.566     89     87
##   0.665 - 0.736     13     85
##   0.766 - 0.79      20     34
##   0.885              4    140
##   0.89 - 0.957       0     14
HL_2$expected
##                
## subgroup         y0_expect  y1_expect
##   0.104         413.970751  48.029249
##   0.199         134.615153  33.384847
##   0.225         667.619238 194.380762
##   0.251          35.944389  12.055611
##   0.407         103.768314  71.231686
##   0.418 - 0.566  77.995475  98.004525
##   0.665 - 0.736  26.219038  71.780962
##   0.766 - 0.79   11.876928  42.123072
##   0.885          16.513424 127.486576
##   0.89 - 0.957    1.477291  12.522709

結構統計量が異なる事がわかる。ただ、いずれの場合もそれぞれのサブグループに含まれるデータポイント数が均等とは言い難い。

そもそも、サブグループに含まれるデータポイント数がある程度10個のサブグループで均等にできる場合には十分位で充分に均等にデータが分割できる状態にあると思うので、そちらの方でサブグループに分けたほうがいいと思う。こういう手法じゃないと均等にサブグループのデータポイント数が均等にならないパターンを考えてみたけど思い至らなかった。

ところで、この場合、サブグループ数は期待確率のユニークな値の数を超えることはできない。

なので、たとえば、今回の場合でいうと15個以上のサブグループをつくって計算しようとするとエラーが出るようにしている。

infun::hosmer_test(model, 17)
## Error in infun::hosmer_test(model, 17): Cannot be calculated because the unique value of expected value is less than the number of groups specified.

また、サブグループ数と期待確率のユニークな値の数が同じ場合は、期待確率のグループのまま計算される。

HL_3 <- infun::hosmer_test(model, 14)
HL_3
## 
##  Hosmer and Lemeshow goodness of fit test
## 
## data:  glm(formula = Survived ~ ., family = binomial(), data = df)
## X-squared = 103.83, df = 12, p-value < 2.2e-16
HL_3$observed
##         
## subgroup y0_obs y1_obs
##    0.104    387     75
##    0.199    154     14
##    0.225    670    192
##    0.251     35     13
##    0.407    118     57
##    0.418      0     11
##    0.566     89     76
##    0.665      0      5
##    0.736     13     80
##    0.766      3     20
##    0.79      17     14
##    0.885      4    140
##    0.89       0     13
##    0.957      0      1
HL_3$expected
##         
## subgroup    y0_expect    y1_expect
##    0.104 413.97075096  48.02924904
##    0.199 134.61515302  33.38484698
##    0.225 667.61923756 194.38076244
##    0.251  35.94438869  12.05561131
##    0.407 103.76831430  71.23168570
##    0.418   6.40677999   4.59322001
##    0.566  71.58869506  93.41130494
##    0.665   1.67537546   3.32462454
##    0.736  24.54366229  68.45633771
##    0.766   5.38076244  17.61923756
##    0.79    6.49616527  24.50383473
##    0.885  16.51342436 127.48657564
##    0.89    1.43440470  11.56559530
##    0.957   0.04288589   0.95711411

これはつまりどういうことかと言うと、今回の場合は当てはまらないが、期待確率のユニークな値の数が10個を下回るときには10個のサブグループは作れないよということ。

もちろん、これを指定されたサブグループ数が大きすぎるから計算出来ないよ~とワーニングメッセージを出しながら計算可能な最大のサブグループ数で計算し直した結果を返す処理にしても良いと思うが、勝手に自由度が変わる処理は混乱を招きかねないかなと思っている。

最後の方は自作関数の紹介になってしまった。

蛇足

こちらで現在CRANで公開されている{ResourceSelection}hoslme.test()では期待確率の出現頻度が偏っているときには、正しい答えを出していない可能性について示した(この問題についてGithub上で正しい答えを出していない可能性について、Issueで報告し修正してもらっている)。

indenkun.hatenablog.com

エラーが出ているわけでもないので一見正しい結果を返しているように見えるが、$observed$expectedで中身を確認すると期待したサブグループ数で分割されていないのに、期待されたサブグループ数で分割された場合の自由度のχ二乗分布で検定されているという問題だった。

例えばHosmer-Lemeshow testで計算された統計量が15のとき、自由度8(サブグループ数10)の場合と自由度6(サブグループ数8)の場合では次のようにp値が異なる。

pchisq(15, 8, lower.tail = FALSE)
## [1] 0.05914546
pchisq(15, 6, lower.tail = FALSE)
## [1] 0.02025672

つまり、サブグループ数10を指定し十分位を取るときに重複する値が有り、サブグループ数が実際には8になってしまった場合に、本来は自由度6でp値が計算されるべきなのに、自由度8でp値が計算されてしまった場合にはモデルは適合していると判断される(されていた)可能性があるということになる。

このことについて色々思うところはあるけれど、それなりに長く使われていて知名度のあるパッケージの関数でこういう単純な謎挙動があるのは、きっと誰か気付いていたんじゃないかなーとは思わななくもない(調べた範囲ではこのことに言及しているものは見当たらなかったけど)。