Rでデータクレンジング
えたいのしれないCSVふぁいるがあらわれた!!
データ分析の業務時間の8割を占めるとかなにやらな前処理、そのその頭に位置するデータクレンジング。手を抜くと大変な事態を招きかねない工程ですが、あまり時間をかけたくはありません。そこで、この工程を少しでも効率的に進めるために、tidyverseパッケージなどを用いた方法を紹介します。
(用語定義) 論理式: T/Fを返す関数など全般、 df:tbl_dfのこと
## 本記事では以下のパッケージを利用しております library(tidyverse) library(stringr)
各種関数の用法
map()の用法補足
lapply()と同様に、リストを渡すとリスト毎に、dfを渡すと列毎に関数を適用します。ラムダ式 (map(~ 関数など)
) を用いた書式が直感的にわかりやすいと思います。
iris %>% map(~ class(.)) # map()の返り値はリスト iris[,-5] %>% map_df(~ range(.)) # map_df()だと帰り値はdf形式 (各返り値の数が揃っている必要あり) iris[, -5] %>% map_dbl(~ mean(.)) # map_要素の型()だと返り値はベクトル形式 (各返り値の数が一つである必要あり)
select_if(), mutate_if()の用法補足
select_if()
やmutate_if()
は引数.predicate
(対象列の指定) の部分などにラムダ式を用いることができます。
面倒なのですが、列名を扱う場合は~
が不要です (列内の要素ではないので評価する必要がないため)。
# factor型以外の列を選択 iris %>% select_if(~ !is.factor(.)) # 単にT/Fを返す関数を一つ入れるだけなら、関数名だけで大丈夫 iris %>% select_if(is.numeric) # factor型以外の列は10倍してint型に変換 iris %>% mutate_if(~ !is.factor(.), ~ as.integer(. * 10))) # 列名がSepal.LengthかSepal.Withの列は、10倍してint型に変換 iris %>% mutate_if(names(.) %in% c("Sepal.Length", "Sepal.With"), ~ as.integer(. * 10))
基本的な流れ
map_res <- df %>% map_dbl(~ sum(論理式))
で気になる要素が各列に何個あるのかを求め、必要に応じてdf %>% filter(論理式)
などで具体的に抽出して修正の必要があるかを確認し、df %>% mutate(...)
や df %>% mutate_if(...)
で修正を行う、というのが基本的な流れとなります。
## irisデータで5という値をなんらかの理由で999に修正したい場合 map_res <- iris %>% select_if(is.numeric) %>% # 処理時間を減らすため、型などで可能性のある列のみ選んでおきます map_dbl(~ sum(. == 5)) # mapで各列に5が幾つあるか数え上げます
## Sepal.Length Sepal.Width Petal.Length Petal.Width ## 10 0 4 0
## 必要に応じて具体的に眺めてみます iris %>% filter(Sepal.Length == 5) names(map_res)[map_res > 0] # 補足:これで気になる要素が存在する列名が得られます ## 気になる要素が存在する列を対象に、if_else()で書き換えます res <- iris %>% mutate_if(names(.) %in% names(map_res)[map_res > 0], ~ if_else(. == 5, 999, .)) ## もちろん、今回の例のように大した列数でないのならばmutate()で十分です iris %>% mutate(Sepal.Length = if_else(Sepal.Length == 5, 999, Sepal.Length), Petal.Length = if_else(Petal.Length == 5, 999, Petal.Length))
Sepal.Length | Sepal.Width | Petal.Length | Petal.Width | Species |
---|---|---|---|---|
5.1 | 3.5 | 1.4 | 0.2 | setosa |
4.9 | 3.0 | 1.4 | 0.2 | setosa |
4.7 | 3.2 | 1.3 | 0.2 | setosa |
4.6 | 3.1 | 1.5 | 0.2 | setosa |
999.0 | 3.6 | 1.4 | 0.2 | setosa |
実例編
データ
それでは、以下のcsvファイルのデータをクレンジングしていきましょう 。
見づらいので補足しますと、seminar_nameちゅら〜の末尾とtypeの白紙マスには全角空白が入っています。コンソールで表示される\
は"
のエスケープ文字なので気にしなくて大丈夫です。
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
test | test | 19800101 | 00001 | 19800101 | 社員 | test |
S00001 | 社員研修 | 20170820 | 00012 | 19800101 | てすと | |
S00123 | DATUM "サマースクール" | 20170820100000 | 00133 | 20000101 | NA | |
S00123 | DATUM "サマースクール" | 20170820100000 | 00011 | 19870516 | guest | NA |
S00123 | DATUM "サマースクール" | 20170820 | 00165 | 20000101 | NA | |
S00159 | DATUM "ウィンタースクール" | 20171215090000 | 00011 | 20000101 | vip | NA |
S00159 | DATUM "ウィンタースクール" | 20171215090000 | 00802 | 20010410 | ゲスト | NA |
S00572 | ちゅら夏セミナー | 20170925093000 | 00866 | 19971128 | NA | |
S00572 | ちゅら夏セミナー | 20170925093000 | 00866 | 19900505 | NA | NA |
S00598 | ちゅら夏セミナー (追加) | 20170926 | 01904 | 20000101 | NA | NA |
S00604 | ちゅら秋セミナー | 20171010100000 | 02175 | 20000101 | NA | NA |
S00604 | ちゅら秋 | 20171010100000 | 02212 | 19891205 | guest | NA |
読み込み
何はともあれ読み込まないと始まらなりません。ダーティーデータ相手なら、汎用性が高く読み込み速度もそこそこなread_csv()
がおすすめです。col_typesは列数が少ない場合、全列characterが無難です。
#### あたりをつけるために、頭5行だけを読み込み。 test_read <- read_csv("https://datumstudio.jp/wp-content/uploads/2017/09/170912_data_cleansing.csv", col_types = cols(.default = "c"), # 全列character # na = c("", "NA"), # ""と"NA"をNAに置換されたくない場合はここを弄ります # trim_ws = TRUE, # 要素末尾の半角スペースを削除されたくない場合はここをFALSEにします n_max = 5) test_read[1:2,]
## # A tibble: 2 x 7 ## seminar_code seminar_name seminar_date customer_id birthday ## <chr> <chr> <chr> <chr> <chr> ## 1 test test 19800101 00001 19800101 ## 2 S00001 "\u008eЈ\U0014c90fC" 20170820 00012 19800101 ## # ... with 2 more variables: type <chr>, memo <chr>
みごとに文字化け、encodingがShift_JIS (正確にはCP932) なのでしょう (encodingが違うと表示すらできないことも)。
列数が多い場合、col_typesは最初は自動判別で読み込み、warningが出た場合その列のcol_typesをcharacterにして再度読み込む、というのが現実的です。
d1 <- read_csv("https://datumstudio.jp/wp-content/uploads/2017/09/170912_data_cleansing.csv", col_types = cols(seminar_date = "c", birthday = "c"), # 日付系はchr型にしておくと吉。 locale = locale(encoding = "CP932"))
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
test | test | 19800101 | 00001 | 19800101 | 社員 | test |
S00001 | 社員研修 | 20170820 | 00012 | 19800101 | てすと | |
S00123 | DATUM “サマースクール” | 20170820100000 | 00133 | 20000101 | NA | |
S00123 | DATUM “サマースクール” | 20170820100000 | 00011 | 19870516 | guest | NA |
NA関連
なにはともあれNAの処理です。map()を用いて探索し、NA専用の置換関数であるreplace_na()
で潰します。
## 各列にNAがあるか d1 %>% map_lgl(~ any(is.na(.))) ## 各列に幾つNAがあるか d1 %>% map_dbl(~ sum(is.na(.))) ## 全てNAの列があるか d1 %>% map_lgl(~ all(is.na(.))) ## NA潰し ( replace_na( list(列名1 = NAを置換する値, 列名2 = ...)) ) d1 %>% replace_na(list(seminar_name = "不明"))
要素頭・末尾の空白潰し
read_csv()の自動削除機能は全角空白には働かないため、"全角空白"
などには無力です。しっかり自力で潰す必要があります。
## 要素頭・末尾の空白が各列にいくつ存在するか space_num <- d1 %>% select_if(is.character) %>% # chr型な列にしか存在し得ないので、そのような列を選択 map_dbl(~ sum(str_detect(., "^[:space:]|[:space:]$"), na.rm = T)) ## str_trim()で空白削除 (str_trim()は全角空白や"\n"も漏らさず消去してくれます) d2 <- d1 %>% mutate_if(names(.) %in% names(space_num)[space_num > 0], ~ str_trim(., side = "both"))
空文字チェック
特に指定していない場合、元々の""はread_csv()
で既にNA置換されていますが、空白潰しの結果、””が生じている可能性ががあります、サボらずチェックしましょう。
流れは前述の空白潰しと同じです。
## 各列に空文字""が幾つ存在するか d2 %>% select_if(is.character) %>% # ""はdbl列などには存在しないので、型で列選択をして処理速度UP map_dbl(~ sum(. == "", na.rm = T))
## seminar_code seminar_name seminar_date customer_id birthday ## 0 0 0 0 0 ## type memo ## 4 0
## とりえあずNAに置換することにします d3 <- d2 %>% mutate(type = na_if(type, ""))
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
test | test | 19800101 | 00001 | 19800101 | 社員 | test |
S00001 | 社員研修 | 20170820 | 00012 | 19800101 | NA | てすと |
S00123 | DATUM “サマースクール” | 20170820100000 | 00133 | 20000101 | NA | NA |
S00123 | DATUM “サマースクール” | 20170820100000 | 00011 | 19870516 | guest | NA |
S00123 | DATUM “サマースクール” | 20170820 | 00165 | 20000101 | NA | NA |
S00159 | DATUM “ウィンタースクール” | 20171215090000 | 00011 | 20000101 | vip | NA |
S00159 | DATUM “ウィンタースクール” | 20171215090000 | 00802 | 20010410 | ゲスト | NA |
S00572 | ちゅら夏セミナー | 20170925093000 | 00866 | 19971128 | NA | NA |
S00572 | ちゅら夏セミナー | 20170925093000 | 00866 | 19900505 | NA | NA |
S00598 | ちゅら夏セミナー (追加) | 20170926 | 01904 | 20000101 | NA | NA |
S00604 | ちゅら秋セミナー | 20171010100000 | 02175 | 20000101 | NA | NA |
S00604 | ちゅら秋 | 20171010100000 | 02212 | 19891205 | guest | NA |
その他探索用コード例 (以下例では、正規表現を用いて全角文字を探索しています)
## map_lgl(~ any(論理式)):該当する要素を各列が含むかどうか d3 %>% map_lgl(~ any(str_detect(., "[^\x01-\x7E]"), na.rm = T)) ## map_dbl(~ sum(論理式)):該当する要素が各列にいくつあるか d3 %>% map_dbl(~ sum( str_detect(., "[^\x01-\x7E]"), na.rm = T )) ## map(~ which(論理式)):何行目の要素が該当するのか (行番号はころころ変わるため、私的には非推奨です) d3 %>% map(~ which(str_detect(., "[^\x01-\x7E]"))) ## filter(特定の列に対する論理式) や filter_all(any_vars(論理式)) で該当する要素を含む行を具体的に抽出 d3 %>% filter(str_detect(seminar_code, "[^\x01-\x7E]")) ## 全角文字をいずれかの列に含む全行を抽出する例 (データサイズ次第では大変時間がかかります、注意) d3 %>% filter_all(any_vars( str_detect(., "[^\x01-\x7E]") ))
##せっかくなのでseminar_codeに混ざっている全角文字を修正して起きます d4 <- d3 %>% mutate(seminar_code = if_else(seminar_code == "S00572", "S00572", seminar_code))
型変換 (主に日時型)
date/datetime型のformatが統一されてる、などという幸運を期待するとバカを見ますよ! ということで、date/datetime型っぽい列は文字数をチェックすることをお勧めします。
d4 %>% select(seminar_date, birthday) %>% map(~ sort(unique(str_length(.))))
## $seminar_date ## [1] 8 14 ## ## $birthday ## [1] 8
## 長い方に合わせてゼロ埋め d5 <- d4 %>% mutate(seminar_date = if_else(str_length(seminar_date) == 8, paste0(seminar_date, "000000"), seminar_date), seminar_date = as.POSIXct(seminar_date, format = "%Y%m%d%H%M%S"), # 見づらいので2行に分けています birthday = as.Date(birthday, format = "%Y%m%d"))
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
test | test | 1980-01-01 00:00:00 | 00001 | 1980-01-01 | 社員 | test |
S00001 | 社員研修 | 2017-08-20 00:00:00 | 00012 | 1980-01-01 | NA | てすと |
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00133 | 2000-01-01 | NA | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00011 | 1987-05-16 | guest | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 00:00:00 | 00165 | 2000-01-01 | NA | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00011 | 2000-01-01 | vip | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00802 | 2001-04-10 | ゲスト | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1997-11-28 | NA | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1990-05-05 | NA | NA |
S00598 | ちゅら夏セミナー (追加) | 2017-09-26 00:00:00 | 01904 | 2000-01-01 | NA | NA |
S00604 | ちゅら秋セミナー | 2017-10-10 10:00:00 | 02175 | 2000-01-01 | NA | NA |
S00604 | ちゅら秋 | 2017-10-10 10:00:00 | 02212 | 1989-12-05 | guest | NA |
ユニークな要素のチェック
このあたりまできたら、具体的な中身も絡めてデータを綺麗にしていきましょう。
## ユニークな要素数のチェック d5 %>% select_if(is.character) %>% map_dbl(~ length(unique(.))) # 引数1つなら、n_distinct(.)よりもlength(unique(.))の方が圧倒的に早いです
## seminar_code seminar_name customer_id type memo ## 7 9 10 5 3
## ユニークな要素数の少ない列については、具体的に眺めておくと幸せになれます d5 %>% select(type, memo) %>% map(~ unique(.))
## $type ## [1] "社員" NA "guest" "vip" "ゲスト" ## ## $memo ## [1] "test" "てすと" NA
気合いと根性でマニュアル修正
上のtype列とmemo列の要素で色々と嫌なものが見えました。type列の一部の要素を置換し、testっぽい文字のある行は消去しましょう。
d6 <- d5 %>% mutate(type = case_when(type == "ゲスト" ~ "guest", is.na(type) ~ "normal", TRUE ~ type)) %>% filter(!memo %in% c("test", "てすと"))
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00133 | 2000-01-01 | normal | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00011 | 1987-05-16 | guest | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 00:00:00 | 00165 | 2000-01-01 | normal | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00011 | 2000-01-01 | vip | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00802 | 2001-04-10 | guest | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1997-11-28 | normal | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1990-05-05 | normal | NA |
S00598 | ちゅら夏セミナー (追加) | 2017-09-26 00:00:00 | 01904 | 2000-01-01 | normal | NA |
S00604 | ちゅら秋セミナー | 2017-10-10 10:00:00 | 02175 | 2000-01-01 | normal | NA |
S00604 | ちゅら秋 | 2017-10-10 10:00:00 | 02212 | 1989-12-05 | guest | NA |
対応チェック
だいぶデータが綺麗になってきました。論理的な対応関係を用いてのチェックを行ってみましょう。
例えば、seminar_codeとseminar_nameには1:1関係が期待されます。このような1:1関係は、以下のような処理を行うと例外が無いかチェックできます。
comb_check <- d6 %>% distinct(seminar_code, seminar_name) %>% add_count(seminar_code) %>% add_count(seminar_name) %>% filter(n > 1 | nn > 1)
seminar_code | seminar_name | n | nn |
---|---|---|---|
S00123 | DATUM “サマースクール” | 2 | 1 |
S00123 | DATUM “サマースクール” | 2 | 1 |
S00604 | ちゅら秋セミナー | 2 | 1 |
S00604 | ちゅら秋 | 2 | 1 |
ちょいちょいと修正しておきます。楽をするためにcase_when()
内で対応がおさまったtbl_dfを用いていますが、tbl_df[行番号, 列番号]
は1要素のみのtbl_df
なので[[1]]で取り出す必要があります。
d7 <- d6 %>% mutate(seminar_name = case_when(seminar_code == comb_check[1, 1][[1]] ~ comb_check[1, 2][[1]], seminar_code == comb_check[3, 1][[1]] ~ comb_check[3, 2][[1]], TRUE ~ seminar_name))
異常値チェック
次に異常値がないかのチェックです。データ数とも相談ですが、numeric型以外の要素は全て数え上げておくと吉。会員の大半が2000年1月1日生まれなど、不思議な現象を見逃さないですみますよ!
freqs <- d7 %>% select_if(~ !is.numeric(.)) %>% map(~ c(sort(table(.), decreasing = T))) # table()で数え上げ、降順ソートし、扱いやすいようベクトル化 freqs %>% map(~ head(.)) # 全部表示すると大変なことになるので、頭のみ for(i in names(freqs)) boxplot(freqs[i], main = i) # 箱ひげ図で可視化すると概観を把握できます
四則演算可能な列は summary()
にぶちこんで要約統計量をチェックしましょう。本例では存在しませんが、numeric型はヒストグラムも書いておくのがお勧めです。(箱ヒゲ図や散布図はデータ量によっては大変なことになるので、要注意です)。
d7 %>% select_if(~ !is.character(.)) %>% summary()
集計する前に
一通りデータクレンジングが終了したら、chr型列、特にgroup_by()
で用いそうな列はfactor型に変換しておきましょう。処理速度がだいぶ変わります。
d8 <- d7 %>% mutate_if(is.character, as.factor)
seminar_code | seminar_name | seminar_date | customer_id | birthday | type | memo |
---|---|---|---|---|---|---|
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00133 | 2000-01-01 | normal | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 10:00:00 | 00011 | 1987-05-16 | guest | NA |
S00123 | DATUM “サマースクール” | 2017-08-20 00:00:00 | 00165 | 2000-01-01 | normal | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00011 | 2000-01-01 | vip | NA |
S00159 | DATUM “ウィンタースクール” | 2017-12-15 09:00:00 | 00802 | 2001-04-10 | guest | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1997-11-28 | normal | NA |
S00572 | ちゅら夏セミナー | 2017-09-25 09:30:00 | 00866 | 1990-05-05 | normal | NA |
S00598 | ちゅら夏セミナー (追加) | 2017-09-26 00:00:00 | 01904 | 2000-01-01 | normal | NA |
S00604 | ちゅら秋セミナー | 2017-10-10 10:00:00 | 02175 | 2000-01-01 | normal | NA |
S00604 | ちゅら秋セミナー | 2017-10-10 10:00:00 | 02212 | 1989-12-05 | guest | NA |
ここまでおつきあいいただき、どうもありがとうございました。
Let’s enjoy R life !!
#### おまけ ## 以下のようなcsvを渡されたとします data.frame(row1 = 'John said "Hello!!"') %>% write.csv("test.csv", row.names = F) ## read_csv() とfread()で読み込んでみますと… read_csv("test.csv") fread("test.csv")
DATUM STUDIOは、クライアントの事業成長と経営課題解決を最適な形でサポートする、データ・ビジネスパートナーです。
データ分析の分野でお客様に最適なソリューションをご提供します。まずはご相談ください。
Contact
Explore Jobs
関連記事