Ch 04. 결측치와 이상치 처리
현실의 데이터는 완전하지 않습니다. 설문에 응답하지 않은 항목, 센서 오류로 빠진 측정값, 입력 실수로 들어온 극단적인 숫자들. 이런 데이터를 그대로 분석하면 결과를 신뢰할 수 없습니다.
결측치(missing value)와 이상치(outlier)를 발견하고 적절히 처리하는 것은 데이터 분석의 기본입니다.
결측치란 무엇인가
R에서 결측치는 NA(Not Available)로 표시됩니다. 어떤 연산을 해도 NA가 전파됩니다.
x <- c(1, 2, NA, 4, 5)
sum(x)
[1] NA
하나라도 NA가 있으면 결과가 NA가 됩니다. 이것은 의도적인 설계입니다. "모르는 값이 포함되어 있으므로 결과도 알 수 없다"는 논리입니다.
결측치를 무시하고 계산하려면 na.rm = TRUE를 씁니다.
sum(x, na.rm = TRUE)
mean(x, na.rm = TRUE)
[1] 12
[1] 3
결측치 확인하기
is.na()는 각 원소가 NA이면 TRUE, 아니면 FALSE를 반환합니다.
is.na(x)
[1] FALSE FALSE TRUE FALSE FALSE
데이터프레임에서 열별 결측치 개수를 확인합니다.
library(tidyverse)
# 실습용 데이터 생성
patients <- tibble(
id = 1:8,
name = c("김철수", "이영희", "박민준", NA, "정수진", "최동현", NA, "윤서연"),
age = c(35, 42, NA, 28, 55, NA, 31, 47),
weight = c(72.5, 58.0, 81.3, 65.0, NA, 78.4, 69.2, NA),
bp = c(120, NA, 135, 118, 142, 127, NA, 139)
)
patients
# A tibble: 8 × 5
id name age weight bp
<int> <chr> <dbl> <dbl> <dbl>
1 1 김철수 35 72.5 120
2 2 이영희 42 58.0 NA
3 3 박민준 NA 81.3 135
...
열별로 결측치 개수를 세는 방법은 여러 가지입니다.
# 각 열의 결측치 수
colSums(is.na(patients))
id name age weight bp
0 2 2 2 2
# tidyverse 방식
patients %>%
summarise(across(everything(), ~ sum(is.na(.x))))
# A tibble: 1 × 5
id name age weight bp
<int> <int> <int> <int> <int>
1 0 2 2 2 2
filter()로 결측치 행 제거
is.na()와 filter()를 조합하면 특정 열에 결측치가 있는 행을 제거할 수 있습니다.
# age가 결측치인 행 제거
patients %>% filter(!is.na(age))
!is.na()는 "NA가 아닌 것"을 의미합니다.
drop_na()로 결측치 제거
tidyr의 drop_na()는 더 편리합니다.
# 모든 열에서 NA가 하나라도 있는 행 제거
patients %>% drop_na()
# A tibble: 2 × 5
id name age weight bp
<int> <chr> <dbl> <dbl> <dbl>
1 1 김철수 35 72.5 120
2 5 정수진 55 NA 142
# 특정 열에만 적용
patients %>% drop_na(age, weight)
drop_na()에 열 이름을 지정하면 그 열들에서만 NA를 확인합니다. 지정한 열에 NA가 있는 행만 제거합니다.
replace_na()로 결측치 대체
결측치를 제거하는 것이 항상 옳지는 않습니다. 특정 값으로 채우는 것이 더 나을 때도 있습니다.
# NA를 특정 값으로 대체
patients %>%
mutate(
age = replace_na(age, mean(age, na.rm = TRUE)),
weight = replace_na(weight, median(weight, na.rm = TRUE))
)
# A tibble: 8 × 5
id name age weight bp
<int> <chr> <dbl> <dbl> <dbl>
1 1 김철수 35 72.5 120
2 2 이영희 42 58.0 NA
3 3 박민준 38.3 81.3 135
...
3번 환자의 age가 평균값 38.3으로 채워졌습니다. 이를 평균 대체법(mean imputation)이라 합니다. 데이터를 잃지 않는 장점이 있지만, 분포를 인위적으로 좁힌다는 단점도 있습니다.
문자형 열의 경우도 동일합니다.
patients %>%
mutate(name = replace_na(name, "이름없음"))
이상치 탐지: IQR 방법
이상치는 다른 값들과 크게 동떨어진 값입니다. 탐지 방법은 여러 가지이지만, IQR(사분위 범위) 기반 방법이 가장 널리 쓰입니다.
# 연습 데이터
scores <- c(72, 85, 78, 91, 68, 83, 77, 95, 200, 74, 88, 5)
Q1 <- quantile(scores, 0.25)
Q3 <- quantile(scores, 0.75)
IQR_val <- IQR(scores)
lower <- Q1 - 1.5 * IQR_val
upper <- Q3 + 1.5 * IQR_val
cat("Q1:", Q1, "\n")
cat("Q3:", Q3, "\n")
cat("IQR:", IQR_val, "\n")
cat("하한:", lower, "\n")
cat("상한:", upper, "\n")
Q1: 74.25
Q3: 87.5
IQR: 13.25
하한: 54.375
상한: 107.375
하한 54.375, 상한 107.375를 벗어나는 값(5, 200)이 이상치입니다.
# 이상치 탐지
outliers <- scores[scores < lower | scores > upper]
outliers
[1] 5 200
데이터프레임에서 이상치 처리
mpg 데이터에서 hwy 연비의 이상치를 확인해봅니다.
Q1 <- quantile(mpg$hwy, 0.25)
Q3 <- quantile(mpg$hwy, 0.75)
IQR_val <- IQR(mpg$hwy)
lower <- Q1 - 1.5 * IQR_val
upper <- Q3 + 1.5 * IQR_val
# 이상치 행 확인
mpg %>% filter(hwy < lower | hwy > upper)
# A tibble: 2 × 11
manufacturer model displ year cyl trans drv cty hwy fl class
<chr> <chr> <dbl> <int> <int> <chr> <chr> <int> <int> <chr> <chr>
1 volkswagen jetta 1.9 1999 4 manual(m5) f 33 44 d compact
2 volkswagen new beetle 1.9 1999 4 manual(m5) f 35 44 d subcompact
이상치를 처리하는 방법은 상황에 따라 다릅니다.
# 방법 1: 이상치 행 제거
mpg_clean <- mpg %>% filter(hwy >= lower & hwy <= upper)
# 방법 2: 이상치를 NA로 표시
mpg_marked <- mpg %>%
mutate(hwy = if_else(hwy < lower | hwy > upper, NA_real_, hwy))
# 방법 3: 상한/하한값으로 대체 (winsorizing)
mpg_winsor <- mpg %>%
mutate(hwy = pmin(pmax(hwy, lower), upper))
단순히 제거하면 분석에서 귀중한 정보를 잃을 수 있습니다. 맥락을 고려해 처리 방법을 선택해야 합니다. 위의 hwy 44인 폭스바겐 차량은 실제 고연비 차량이므로 제거하면 안 되는 사례입니다.
결측치와 이상치 처리의 원칙
데이터를 정제할 때 기억할 점이 있습니다.
첫째, 원본 데이터는 건드리지 않습니다. 정제된 데이터를 새 객체에 저장합니다.
둘째, 처리 전과 후의 행 수를 기록합니다. 얼마나 많은 데이터가 제거되었는지 보고서에 남깁니다.
셋째, 결측치와 이상치가 왜 발생했는지 이해합니다. 원인에 따라 처리 방법이 달라집니다.
# 처리 전후 비교
nrow(mpg) # 234
nrow(mpg_clean) # 232
데이터가 깨끗해졌습니다. 이제 분석에 필요한 새 변수를 만들어볼 차례입니다.