備忘ログ

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

Rでdata frameを標準化するメモ(関数作った&パッケージ化した)

Rで値を標準化するときに{base}scale()(つまり普通のscale())を使うが、データフレームを標準化するときには注意が必要となる。

当たり前だが、標準化するという目的上{base}scale()は数値型しか基本的には受け付けない1

なのでデータフレームを標準化しようとしたときに次のように数値型じゃない列(irisデータのSpeciesが因子型)を含んでいるとエラーが出る。

scale(iris)
## Error in colMeans(x, na.rm = TRUE):  'x' は数値でなければなりません

回避策としては次のように数値のところだけ抜き出して標準化するとよい(irisデータの1~4列目は数値型)。

d <- scale(iris[1:4])
# 長いのでhead()で冒頭のみ表示
head(d)
##      Sepal.Length Sepal.Width Petal.Length Petal.Width
## [1,]   -0.8976739  1.01560199    -1.335752   -1.311052
## [2,]   -1.1392005 -0.13153881    -1.335752   -1.311052
## [3,]   -1.3807271  0.32731751    -1.392399   -1.311052
## [4,]   -1.5014904  0.09788935    -1.279104   -1.311052
## [5,]   -1.0184372  1.24503015    -1.335752   -1.311052
## [6,]   -0.5353840  1.93331463    -1.165809   -1.048667

数値のところだけ標準化して因子型や文字列のデータを含んだデータにしたかったら、例えば次のように数値のところだけ標準化して、数値じゃないところをくっつける作業をする。

df <- cbind(scale(iris[1:4]), iris[5])
# 長いのでhead()で冒頭のみ表示
head(df)
##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1   -0.8976739  1.01560199    -1.335752   -1.311052  setosa
## 2   -1.1392005 -0.13153881    -1.335752   -1.311052  setosa
## 3   -1.3807271  0.32731751    -1.392399   -1.311052  setosa
## 4   -1.5014904  0.09788935    -1.279104   -1.311052  setosa
## 5   -1.0184372  1.24503015    -1.335752   -1.311052  setosa
## 6   -0.5353840  1.93331463    -1.165809   -1.048667  setosa

小さいデータだとそんなに手間じゃないが、少し大きデータになったり、数値型と文字列・因子型が入り乱れている場合にはちょっとめんどくさい。

こんな判定は勝手に機械でやってほしい。

というわけで、データフレームを標準化するときに数値型なら標準化して、そうでなければそのままの値のままにしておいてくれるscale()ジェネリック関数を作った(需要がありそうなので世の中(CRAN)を探せばありそうではあるがぱっと見つけられなかった)。

scale.data.frame <- function(x, center = TRUE, scale = TRUE){
  if(all(class(x) != "data.frame")){
    warning("Only one data frames can be handled.")
    return(NA)
  }

  ans.list <- lapply(x, function(x){
    if(is.numeric(as.matrix(x))) scale.default(x, center, scale)
    else x
  })

  do.call(cbind.data.frame, ans.list)
}

使ってみる。ジェネリック関数なので上記の関数を読み込んだら(か下にリンクを貼り付けたパッケージをlibrary()で読み込んだら)、scale()の標準化対象のオブジェクトのクラスがdata.frameなら勝手にこっちで処理される。

z.iris <- scale(iris)
# 長いのでhead()で冒頭のみ表示
head(z.iris)
##   Sepal.Length Sepal.Width Petal.Length Petal.Width Species
## 1   -0.8976739  1.01560199    -1.335752   -1.311052  setosa
## 2   -1.1392005 -0.13153881    -1.335752   -1.311052  setosa
## 3   -1.3807271  0.32731751    -1.392399   -1.311052  setosa
## 4   -1.5014904  0.09788935    -1.279104   -1.311052  setosa
## 5   -1.0184372  1.24503015    -1.335752   -1.311052  setosa
## 6   -0.5353840  1.93331463    -1.165809   -1.048667  setosa

数字で入力されていても、その値が文字列だったり因子型だったらそのまま値を返すように処理するようにしている。

# サンプルデータを作る。value1は数字が因子、value2は数字が文字、value3は数字が値
sample.df <- data.frame(value1 = factor(c(1,2,1,2,3), levels = c(1:4)),
                        value2 = as.character(c("1","2","1","2","3")),
                        value3 = c(1, 2, 1, 2, 3))

# 型の確認。や因子型が値は1~3だけどレベルが1~4になっているのを確認する。
str(sample.df)
## 'data.frame':    5 obs. of  3 variables:
##  $ value1: Factor w/ 4 levels "1","2","3","4": 1 2 1 2 3
##  $ value2: chr  "1" "2" "1" "2" ...
##  $ value3: num  1 2 1 2 3
# データフレームをまるっと数値のみ標準化する。
z.sample.df <- scale(sample.df)

# 中身を見てみる。
z.sample.df
##   value1 value2     value3
## 1      1      1 -0.9561829
## 2      2      2  0.2390457
## 3      1      1 -0.9561829
## 4      2      2  0.2390457
## 5      3      3  1.4342743
# 型の確認。因子型が値は1~3だけどレベルが1~4になっているのを確認する。
str(z.sample.df)
## 'data.frame':    5 obs. of  3 variables:
##  $ value1: Factor w/ 4 levels "1","2","3","4": 1 2 1 2 3
##  $ value2: chr  "1" "2" "1" "2" ...
##  $ value3: num  -0.956 0.239 -0.956 0.239 1.434

ちゃんと、文字列型や因子型のところは変換されず(型も保持される、勝手に因子型を文字列型とかにはしないし因子はレベルも保持される)、数値のところだけが標準化される。

パッケージ化した

今までブログ上で書き散らかした関数(以外もある)を少し関数名や細かい挙動を調整して、上記の関数もまとめたものをパッケージ化してGitHubからインストールできるようにした(GitHubソースコードを管理したいだけ)。

GitHub - indenkun/infun: This is a collection of R utilities functions for me, but maybe also for you.

install.packages("remotes")
remotes::install_github("indenkun/infun")

でインストールできる。詳しくはないが関数の紹介はREADMEに書いたつもり。

自分で使いたい関数をパッケージ化した感じで今後も適当に自分で使いたい関数があれば適宜入れていく。自作関数であっても汎用性があるものは適宜パッケージ化したほうが名前空間的にいいし、Rスクリプトファイルの紛失が防げる。

雑感

データフレーム(数値型と因子型、文字列型が混在しているもの)の中の数値を標準化する用事(標準化する必要があったのは数値型全部ではなかったが)があって、そのときに数値型を選ぶか因子型や文字列型のところを取り除いてやるのがめんどくさいなぁと思って作った。正しいデータハンドリングとしてはちゃんとここを標準化するんだよという、ちゃんと狙って標準化するのがいい気がするが、とりあえずデータフレームを投入して数値は全部標準化しておきたいという横着したい気持ちがにじみ出ている関数である。

作ってて困ったのは、最初上で載せた関数のコードのところでlappy()じゃなくてsapply()を使って直接さっさとデータフレームを作ってやろうと思ったら、因子型をそのままの値で返すようにしているのになんだかんだ中であって因子型のレベルで値になってしまう自体になってしまった。

そこで打開策としてlappy()でリストにして(リストにしておけば因子型は因子型のままレベルも保持される)、cbind()でリストをくっつけていこうと考えた。

しかしここでもだたのcbind()だとまたもや因子型がレベルの値に置き換わってしまうので、直接cbind.data.frame()を指定することで回避することに成功した。

あとは、そもそも列の型判定をするときに数値型かそうでないかを判定するだけでいいのだが、データフレームで例えばirisの1列目は数値型なのだが、

is.numeric(iris[1])
## [1] FALSE

FALSEになってしまうという問題に見舞われた。apply系の関数じゃなくてfor関数なら

is.numeric(iris[,1])
## [1] TRUE

という判定で数字を入れ替えながら回すこともできたが、なんとなくapply系で書いたほうがかっこよさそうだと思ったので、

is.numeric(as.matrix(iris[1]))
## [1] TRUE

as.matrix()をかませてこの問題を回避することにした。あまりスマートな方法じゃない気がするのでなにかいい方法がわかったら置き換えるかもしれない。

あと最初にクラスを判定するコードを入れているが、S3のジェネリック関数なのでdata.fame以外が入ってくることはなさそうだが、infun:::scale.data.frame()でクラスに関係なく強制的に呼び出されたときにたとえば「データフレームを強制的に標準化してくれるんだろー」とデータフレームをc()で雑に2つ突っ込まれたらどういう挙動をさせるのか(この場合クラスはリストになるので受け付けないよというのでもいいが)など決めるのが面倒だったので、型判定と見せかけて、データフレーム一個しか受け付けないよというちょっとわけのわからない判定用コードにしている。

ただ、判定用のコードならclass(x) != "data.frame"でも良さそうだと思うかもしれないが、世の中には

class(ChickWeight)
## [1] "nfnGroupedData" "nfGroupedData"  "groupedData"    "data.frame"

みたいにクラスいっぱい持ってる組み込みデータのデータフレームもあるのでこういうのを処理できるようにしている。

2021/04/10追記:{e1071}scale_data_frame()との差異

上記のscale.data.frame()作ってから、{e1071}scale_data_frame()を見つけて「これは車輪の再発明してしまった(楽しくやれたのでヨシ(๑•̀ㅂ•́)و✧)」と思った。

で、{e1071}scale_data_frame()のコードを見てみるとscale.data.frame()とはちょっと挙動が異なる様子。

具体的な例としては次のようなデータフレームを作る。

df <- data.frame(value1 = c(T, F, T, F, T),
                 value2 = 1:5,
                 value3 = factor(c(1, 2, 3, 2, 1), levels = 1:10),
                 value4 = as.character(1:5))

value1は論理型、value2が数値、value3が因子型、value4が文字列になっている数字となっている。

このとき、{e1071}scale_data_frame()では次のようになる。

e1071::scale_data_frame(df)
##       value1     value2 value3 value4
## 1  0.7302967 -1.2649111      1      1
## 2 -1.0954451 -0.6324555      2      2
## 3  0.7302967  0.0000000      3      3
## 4 -1.0954451  0.6324555      2      4
## 5  0.7302967  1.2649111      1      5

scale.data.frame()では次のようになる({infun}から:::で強制的に呼び出す、前述しているようにいずれかの手法で関数をロードしていればscale()で呼び出せる)。

infun:::scale.data.frame(df)
##   value1     value2 value3 value4
## 1   TRUE -1.2649111      1      1
## 2  FALSE -0.6324555      2      2
## 3   TRUE  0.0000000      3      3
## 4  FALSE  0.6324555      2      4
## 5   TRUE  1.2649111      1      5

出力結果を見てもらえればすぐに分かるように、{e1071}scale_data_frame()の方は論理型の方もTRUE1FALSE0の数値(論理型の実態は数字の0、1なのでそれに従っている)として扱われて標準化されている。

これは意図せずこういう挙動になっているのではなく、{e1071}scale_data_frame()ソースコードを読むと、標準化するかどうか判定対象を選ぶときに

i <- vapply(x, is.numeric, NA) | vapply(x, is.logical, NA)

としていて、意図的に理論型も変換できる対象に含めるようにしている。

確かに、デフォルトのscale.defalt()

scale.default(df$value1)
##            [,1]
## [1,]  0.7302967
## [2,] -1.0954451
## [3,]  0.7302967
## [4,] -1.0954451
## [5,]  0.7302967
## attr(,"scaled:center")
## [1] 0.6
## attr(,"scaled:scale")
## [1] 0.5477226

と論理型を標準化できるけれども……。データフレームを流し込んだときに意図せず型まで変えてしまうのはやや微妙な気がするがそれは使用用途次第なのか?とりあえず、自分がやりたかったこと(scale.data.frame())と、{e1071}scale_data_frame()でできることが違ったという話。

別に実現できることが全く一緒でも何が困るわけでもないんだけどねという話。

:追記終了


  1. scale(x = iris, center = FALSE, scale = FALSE)などと何がしたいのかわからない処理なら数値じゃない値(irisデータのSpeciesが因子型)を含んでいても受け付ける。