Rデータ分析 

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は、クライアントの事業成長と経営課題解決を最適な形でサポートする、データ・ビジネスパートナーです。
データ分析の分野でお客様に最適なソリューションをご提供します。まずはご相談ください。

このページをシェアする: