Devin.KR

자료형과 결측값 - NA·NULL·factor

개발자KR 조회 0

이 장에서 배우는 것

앞 장에서는 값을 한 줄로 늘어놓은 벡터를 만들고 다뤘다. 이 장에서는 그 벡터에 들어 있는 값이 어떤 종류인지, 그리고 값이 비어 있을 때 R 이 어떻게 처리하는지를 다룬다. 엑셀에서는 셀 하나에 숫자와 글자가 섞여 있어도 화면에서는 별 문제가 없다. R 은 벡터 하나에 한 가지 종류의 값만 담기 때문에, 종류를 확인하고 필요하면 바꾸는 일이 분석의 첫 단계가 된다.

  • numeric, integer, character, logical 네 가지 자료형을 구별하고 class() 와 typeof() 로 확인한다.
  • 서로 다른 자료형이 한 벡터에 섞일 때 일어나는 자동 형 변환의 방향을 예측한다.
  • NA 와 NULL 이 무엇이 다른지 설명하고, is.na() 와 na.rm 으로 결측값을 다룬다.
  • factor 가 글자를 정수 번호와 수준 표로 저장한다는 것을 이해하고, 수준을 지정하거나 정리한다.

문제 상황

동네 편의점 사장님이 하루 판매 기록을 계산기로 정리해 두었다가, 이번에는 R 로 옮겨 합계를 내 보려 한다. 그런데 수량 칸에 손으로 적다가 빠뜨린 곳이 하나 있다. 옮겨 담을 때 수량이 글자("2", "1", "3")로 들어왔고, 빠뜨린 칸은 빈 글자("")가 되었다.

이 상태에서 수량 합계를 구하면 두 가지 문제가 생긴다. 첫째, 글자로 저장된 수량은 더하기 계산이 되지 않는다. 둘째, 숫자로 바꾼 뒤에도 빈 칸은 값이 없는 상태(결측값)로 남고, 이 값이 하나라도 있으면 sum() 의 결과 전체가 NA 가 된다. 분류 열("식품", "음료")은 또 다른 문제를 가진다. 이 열은 글자로 두면 "오늘 팔린 분류"만 알 수 있고, 팔리지 않은 분류가 있었다는 사실은 기록에 남지 않는다.

이 장의 완성 코드는 이 세 가지를 차례로 해결한다. 수량을 숫자로 바꾸고, 빠진 칸을 찾아내 계산에서 다루고, 분류 열을 factor 로 만들어 수준을 관리한다.

자료형과 자동 형 변환

네 가지 기본 자료형

자료형(data type)은 값이 어떤 종류인지를 나타내는 이름이다. 편의점 기록에서는 네 가지가 주로 나온다.

편의점 기록에서 만나는 네 가지 자료형
자료형class() 결과값의 예편의점 기록의 예
실수"numeric"1200, 2.5단가, 금액
정수"integer"3L, length() 의 결과항목 개수, 번호
글자"character""우유", "2"상품 이름, 분류
논리"logical"TRUE, FALSE가격이 1200원 이상인가

R 에서 1200 처럼 소수점 없이 쓴 숫자도 기본적으로 numeric 이다. 정수로 저장하려면 숫자 뒤에 L 을 붙여 1200L 로 쓴다. class() 는 "numeric" 이라고만 답하지만 typeof() 는 내부 저장 방식인 "double" 을 알려 준다. 처음에는 numeric 과 double 을 같은 것으로 읽어도 된다. 분석 목적으로는 정수와 실수의 차이가 드러나는 경우가 드물고, 나눗셈 결과가 소수가 되는 5L / 2L 이 2.5 가 되는 정도만 기억하면 된다.

논리값은 비교 연산의 결과로 자주 만들어진다. price >= 1200 은 가격마다 TRUE 나 FALSE 를 돌려주는 논리 벡터다. 논리값은 계산에서 TRUE 가 1, FALSE 가 0 으로 취급된다. 그래서 논리 벡터에 sum() 을 쓰면 조건을 만족하는 개수가, mean() 을 쓰면 그 비율이 나온다.

자동 형 변환

벡터에는 한 가지 자료형만 들어갈 수 있다. 그래서 c() 로 서로 다른 종류를 묶으면 R 이 값을 하나의 자료형으로 맞춘다. 이 과정을 형 변환(coercion)이라 하고, 사용자가 요청하지 않아도 일어나므로 자동 형 변환이라 부른다. 맞추는 방향은 정해져 있다. 담을 수 있는 정보가 적은 쪽에서 많은 쪽으로 바뀐다.

논리, 정수, 실수, 글자 순서로 오른쪽 자료형에 맞춰지므로 글자가 하나라도 섞이면 벡터 전체가 글자가 된다.

c(1200, "900", TRUE) 를 만들면 글자가 하나 있으므로 세 값이 모두 글자가 되어 "1200", "900", "TRUE" 가 된다. 숫자 1200 이 글자 "1200" 이 되었으니 이 벡터로는 더하기 계산을 할 수 없다. 반대 방향의 변환은 as.numeric(), as.integer(), as.character(), as.logical() 처럼 as. 로 시작하는 함수로 직접 요청한다. as.integer(3.9) 는 반올림하지 않고 소수 부분을 버려 3 을 돌려준다는 점에 주의한다.

글자를 숫자로 바꿀 수 없는 값을 as.numeric() 에 넣으면 그 자리는 NA 가 된다. 이때 "오류" 처럼 숫자로 읽을 수 없는 글자는 경고 메시지(NAs introduced by coercion)를 함께 낸다. 빈 글자 "" 는 경고 없이 NA 가 된다. 이 장의 예제는 후자를 쓴다. 자동 변환의 규칙은 R 의 언어 정의 문서에도 정리되어 있다. 확인이 필요하면 R Language Definition 을 참고한다.

결측값 NA 와 NULL

NA 는 값이 있어야 할 자리가 비어 있다는 표시다

결측값(missing value)은 측정하지 못했거나 기록하지 못해 값을 모르는 상태다. R 에서는 이를 NA 로 나타낸다. NA 는 0 도 아니고 빈 글자도 아니다. "값이 있기는 한데 무엇인지 모른다"는 뜻이다. 이 뜻을 이해하면 NA 가 계산에 퍼지는 이유도 자연스럽다. 2 + NA 는 모르는 수에 2 를 더한 것이므로 답도 모른다. 그래서 NA 가 된다.

같은 이유로 NA == 1 도 TRUE 나 FALSE 가 아니라 NA 다. 모르는 값이 1 인지는 알 수 없기 때문이다. NA == NA 조차 NA 를 돌려주므로, 값이 NA 인지 검사할 때는 == 가 아니라 is.na() 를 써야 한다.

합계나 평균에서 NA 를 건너뛰고 싶으면 na.rm = TRUE 를 준다. na.rm 은 NA 를 제거(remove)하라는 뜻의 인수 이름이다. 이 인수는 sum(), mean(), max(), min() 등 요약 함수 대부분이 가지고 있다.

다만 na.rm = TRUE 는 결과 숫자를 만들어 줄 뿐이고, 무엇이 얼마나 빠졌는지는 알려 주지 않는다. 그래서 sum(is.na(x)) 로 빠진 개수를 먼저 세고 나서 계산하는 습관이 필요하다. 논리 벡터의 합은 TRUE 의 개수이므로 이 식이 NA 의 개수가 된다.

NA 를 다른 값으로 채울 때는 선택에 따라 결과가 달라진다. 0 으로 채우면 "이 판매 건은 0개 팔렸다"는 뜻이 되어 평균이 낮아지고, 제외하면 나머지 값만의 평균이 된다. 어느 쪽이 맞는지는 기록이 비어 있는 이유에 달려 있다. 이 장의 예제는 두 결과를 나란히 보여 준다.

NULL 은 아예 없다는 뜻이다

NULL 은 길이가 0 인 "아무것도 없음"이다. NA 는 자리 하나를 차지하지만 NULL 은 자리를 차지하지 않는다. c(1, NULL, 3) 의 길이가 3 이 아니라 2 인 이유가 여기에 있다. NULL 은 뒤에 나오는 리스트와 함수에서 "결과가 없음"을 나타낼 때 자주 만나게 된다. 지금은 NA 와 구별해 두면 충분하다.

NA, NULL, 0, 빈 글자는 서로 다른 값이다
값length()뜻sum(c(5, 값)) 결과
NA1값이 있어야 하나 모른다NA
NULL0아무것도 없다5
01값이 0 이다5
""1길이가 0 인 글자계산 오류(글자가 섞임)

NA 가 있는 벡터를 걸러낼 때

조건으로 벡터를 걸러낼 때도 NA 는 문제를 만든다. qty[qty > 1] 에서 조건 결과가 NA 인 자리는 선택 결과에도 NA 로 나타난다. 결과에 NA 가 끼어들면 뒤 계산이 다시 NA 가 되므로 걸러낸 의미가 없어진다. 해결책은 which() 를 쓰는 것이다. which() 는 TRUE 인 자리의 번호만 돌려주고 NA 자리는 버린다. 그래서 qty[which(qty > 1)] 로 쓰면 NA 가 결과에 남지 않는다.

factor 와 수준

글자에 번호를 붙여 저장하는 자료형

분류처럼 종류가 몇 가지로 정해진 글자는 factor 로 다룬다. factor 는 값을 정수 번호로 저장하고, 번호가 어떤 글자인지를 적은 수준(levels) 표를 함께 가진다. 화면에는 글자가 보이지만 안쪽에는 번호가 있다. 이 구조가 두 가지 이점을 준다. 분류별로 개수를 세거나 그룹을 나누는 작업이 쉬워지고, 실제 값에 없는 분류도 수준 표에 남길 수 있다.

factor 는 정수 번호를 저장하고 수준 표로 글자를 보여 주며, 값에 없는 생활용품도 수준 표에 남는다.

factor(값, levels = 수준) 으로 만들며, levels 를 생략하면 글자를 가나다순으로 정렬해 수준을 만든다. 일부러 levels 를 지정하면 표에 나올 순서를 정할 수 있고, 값에 없는 분류도 넣을 수 있다. 예제에서는 "생활용품"을 수준에 넣었지만 기록에는 없으므로, 세어 보면 0 이 나온다. "오늘 생활용품은 하나도 팔리지 않았다"는 사실이 표에 남는 것이다. 값에 없는 수준이 필요 없으면 droplevels() 로 정리한다.

순서가 의미 있는 분류(소, 중, 대)는 ordered = TRUE 를 주어 순서형 factor 로 만든다. 이렇게 하면 "대"가 "중"보다 큰지 비교할 수 있다.

factor 를 숫자로 되돌릴 때의 함정

factor 안쪽이 번호이므로 as.numeric() 을 바로 쓰면 글자가 아니라 번호가 나온다. 예를 들어 "500", "1500", "1000" 이 factor 가 되면 수준은 글자 순서로 "1000", "1500", "500" 이 되고, "500" 은 3 번이 된다. 원래 값을 숫자로 얻으려면 as.character() 로 글자를 먼저 꺼낸 뒤 as.numeric() 을 적용한다.

tidyverse 로는 어떻게 쓰나

이 책은 base R 만 쓰므로 아래 표는 소개용이다. 외부 패키지를 설치하지 않아도 이 장의 코드는 모두 돌아간다.

이 장의 base R 함수와 tidyverse 쪽 대응
하는 일base Rtidyverse
수준을 정해 factor 만들기factor(x, levels = v)forcats::fct(x, levels = v)
쓰지 않는 수준 버리기droplevels(f)forcats::fct_drop(f)
NA 를 0 으로 채우기x[is.na(x)] <- 0tidyr::replace_na(x, 0)

factor 함수의 인수는 공식 도움말에서 확인할 수 있다. 콘솔에서 ?factor 를 입력해도 같은 내용이 나온다.

완성 코드

아래 코드를 main.R 로 저장하고 Rscript main.R 로 실행한다. 데이터는 코드 안에서 만들며, 외부 패키지와 난수는 쓰지 않는다.

# main.R - 자료형과 결측값: 편의점 판매 기록 점검
item     <- c("삼각김밥", "생수", "우유", "삼각김밥", "라면", "생수", "우유", "라면")
category <- c("식품", "음료", "음료", "식품", "식품", "음료", "음료", "식품")
qty_text <- c("2", "1", "3", "", "2", "4", "1", "2")
price    <- c(1200, 900, 1500, 1200, 1100, 900, 1500, 1100)

cat("== 1. 자료형 ==\n")
print(class(price))
print(typeof(price))
print(class(length(item)))
print(class(item))
high_price <- price >= 1200
print(class(high_price))
print(5L / 2L)
print(class(5L %/% 2L))

cat("\n== 2. 자동 형 변환 ==\n")
mixed <- c(1200, "900", TRUE)
print(mixed)
print(class(mixed))
print(as.integer(3.9))
print(as.numeric("1500"))
print(sum(high_price))
print(mean(high_price))

cat("\n== 3. 결측값 NA ==\n")
qty <- as.numeric(qty_text)
print(qty)
print(sum(qty))
print(sum(qty, na.rm = TRUE))
print(round(mean(qty, na.rm = TRUE), 2))
print(is.na(qty))
print(sum(is.na(qty)))
print(item[is.na(qty)])
print(qty > 1)
print(qty[qty > 1])
print(qty[which(qty > 1)])
qty_filled <- qty
qty_filled[is.na(qty_filled)] <- 0
print(mean(qty_filled))

cat("\n== 4. NULL ==\n")
print(length(NA))
print(length(NULL))
print(length(c(1, NULL, 3)))
print(is.null(NULL))

cat("\n== 5. factor 와 수준 ==\n")
category_f <- factor(category, levels = c("식품", "음료", "생활용품"))
print(category_f)
print(levels(category_f))
print(as.integer(category_f))
counts <- table(category_f)
cat(paste(paste0(names(counts), ":", as.integer(counts)), collapse = " "), "\n", sep = "")
print(levels(droplevels(category_f)))
size_f <- factor(c("500", "1500", "500", "1000"))
print(as.numeric(size_f))
print(as.numeric(as.character(size_f)))
size_order <- factor(c("소", "대", "중", "소"), levels = c("소", "중", "대"), ordered = TRUE)
print(size_order)
print(size_order[2] > size_order[3])

cat("\n== 6. 분류별 판매 수량 ==\n")
food_qty <- sum(qty[category == "식품"], na.rm = TRUE)
drink_qty <- sum(qty[category == "음료"], na.rm = TRUE)
cat("식품 판매 수량: ", food_qty, "개\n", sep = "")
cat("음료 판매 수량: ", drink_qty, "개\n", sep = "")

줄별 해설

  • 2~5행: 판매 기록 여덟 건을 벡터 네 개로 만든다. 같은 자리의 값들이 한 건의 기록이다. 4번째 기록의 수량은 빈 글자 "" 이고, 수량 벡터는 글자로 저장되어 있다.
  • 7~14행: 자료형을 확인한다. class(price) 는 "numeric", typeof(price) 는 내부 저장 방식인 "double" 이다. length() 의 결과는 정수라서 class 가 "integer" 다. price >= 1200 은 논리 벡터를 만든다. 5L / 2L 은 정수끼리의 나눗셈이지만 2.5 가 나오고, %/% 는 몫만 구하는 연산자라서 정수 2 가 나온다.
  • 16~23행: c(1200, "900", TRUE) 에 글자가 하나 있으므로 세 값이 모두 글자가 된다. as.integer(3.9) 는 3 이다. 논리 벡터의 sum() 은 TRUE 의 개수이고 mean() 은 TRUE 의 비율이다.
  • 25~38행: as.numeric(qty_text) 로 수량을 숫자로 바꾸면 빈 글자가 NA 가 된다. sum(qty) 는 NA 를 그대로 전달하고, na.rm = TRUE 를 주면 나머지 일곱 개를 더한다. is.na() 로 빠진 자리를 찾고, item[is.na(qty)] 로 빠진 기록의 상품 이름을 확인한다. qty > 1 은 NA 자리가 NA 로 남고, qty[qty > 1] 은 그 NA 를 결과에 포함한다. which() 를 거치면 NA 자리가 빠진다. 마지막 세 줄은 NA 를 0 으로 채운 복사본을 만들어 평균을 낸다. 원본 qty 는 그대로 남는다.
  • 40~44행: length(NA) 는 1, length(NULL) 은 0 이다. c(1, NULL, 3) 은 NULL 이 자리를 차지하지 않으므로 길이가 2 다.
  • 46~62행: factor() 에 levels 를 지정해 "생활용품"을 수준에 넣는다. as.integer() 로 안쪽 번호를 확인한다. table() 은 수준별 개수를 세고, names() 와 as.integer() 로 이름과 개수를 꺼내 paste0() 로 "이름:개수" 형태의 글자를 만든다. droplevels() 는 쓰이지 않은 "생활용품"을 뺀다. size_f 는 수준이 글자 순서로 만들어지므로 as.numeric() 이 번호를 돌려주고, as.character() 를 거치면 원래 숫자가 나온다. 순서형 factor 는 > 로 크기를 비교할 수 있다.
  • 64~68행: qty[category == "식품"] 은 식품 기록의 수량만 고른다. 식품 수량에는 NA 가 하나 있으므로 na.rm = TRUE 를 준다. cat() 은 sep = "" 로 이어 붙여 줄 끝에 공백이 남지 않게 한다.

실행 결과

$ Rscript main.R
== 1. 자료형 ==
[1] "numeric"
[1] "double"
[1] "integer"
[1] "character"
[1] "logical"
[1] 2.5
[1] "integer"

== 2. 자동 형 변환 ==
[1] "1200" "900"  "TRUE"
[1] "character"
[1] 3
[1] 1500
[1] 4
[1] 0.5

== 3. 결측값 NA ==
[1]  2  1  3 NA  2  4  1  2
[1] NA
[1] 15
[1] 2.14
[1] FALSE FALSE FALSE  TRUE FALSE FALSE FALSE FALSE
[1] 1
[1] "삼각김밥"
[1]  TRUE FALSE  TRUE    NA  TRUE  TRUE FALSE  TRUE
[1]  2  3 NA  2  4  2
[1] 2 3 2 4 2
[1] 1.875

== 4. NULL ==
[1] 1
[1] 0
[1] 2
[1] TRUE

== 5. factor 와 수준 ==
[1] 식품 음료 음료 식품 식품 음료 음료 식품
Levels: 식품 음료 생활용품
[1] "식품"     "음료"     "생활용품"
[1] 1 2 2 1 1 2 2 1
식품:4 음료:4 생활용품:0
[1] "식품" "음료"
[1] 3 2 3 1
[1]  500 1500  500 1000
[1] 소 대 중 소
Levels: 소 < 중 < 대
[1] TRUE

== 6. 분류별 판매 수량 ==
식품 판매 수량: 6개
음료 판매 수량: 9개

실무에서 자주 틀리는 것

NA 가 있는데 na.rm 을 빠뜨린다

틀린 코드는 다음과 같다. 결과가 NA 라서 계산이 실패한 것처럼 보이지만, 사실은 R 이 규칙대로 답한 것이다.

qty <- c(2, 1, 3, NA, 2)
sum(qty)
# [1] NA

고친 코드는 na.rm = TRUE 를 주되, 빠진 개수를 함께 확인하는 것이다.

sum(is.na(qty))
# [1] 1
sum(qty, na.rm = TRUE)
# [1] 8

NA 를 == 로 찾는다

NA 와의 비교는 항상 NA 이므로 이 식은 어떤 자리도 찾아내지 못한다.

qty == NA
# [1] NA NA NA NA NA

고친 코드는 is.na() 를 쓴다.

is.na(qty)
# [1] FALSE FALSE FALSE  TRUE FALSE

factor 에 as.numeric() 을 바로 쓴다

숫자처럼 보이는 글자가 factor 가 되어 있을 때 이렇게 바꾸면 값이 아니라 번호를 얻는다. 오류도 경고도 나지 않아 눈치채기 어렵다.

size_f <- factor(c("500", "1500", "500", "1000"))
as.numeric(size_f)
# [1] 3 2 3 1

고친 코드는 글자를 먼저 꺼낸다.

as.numeric(as.character(size_f))
# [1]  500 1500  500 1000

수준에 없는 값을 factor 에 넣는다

수준 표에 없는 값을 대입하면 그 자리가 NA 가 되고 경고가 난다(invalid factor level, NA generated).

category_f <- factor(c("식품", "음료"), levels = c("식품", "음료"))
category_f[2] <- "문구"
# Warning message: invalid factor level, NA generated

고친 코드는 수준을 먼저 늘린 뒤 대입한다.

levels(category_f) <- c(levels(category_f), "문구")
category_f[2] <- "문구"
print(category_f)
# [1] 식품 문구
# Levels: 식품 음료 문구

한눈에 보기

이 장에서 쓴 함수와 개념 요약
주제함수·표기기억할 점
자료형 확인class(), typeof()numeric 과 double 은 같은 값을 가리킨다
자동 형 변환c() 로 섞기논리, 정수, 실수, 글자 순서로 오른쪽에 맞춰진다
직접 변환as.numeric() 등읽을 수 없는 글자는 NA 가 되고 경고가 난다(빈 글자는 경고 없음)
결측 확인is.na(), sum(is.na(x))== NA 는 쓰지 않는다
결측 제외 계산na.rm = TRUE빠진 개수를 먼저 확인한다
NULLis.null(), length()길이 0, 자리를 차지하지 않는다
factorfactor(), levels(), droplevels()안쪽은 번호, 수준 표는 값에 없는 분류도 가진다
factor 를 숫자로as.numeric(as.character(f))as.numeric(f) 는 번호를 돌려준다

연습 문제

  1. 다음 세 식의 결과를 실행하기 전에 예측하고, class() 로 확인한다. (가) c(TRUE, 2L) (나) c(1L, 2.5) (다) c(2.5, "a")
  2. prices <- c(1200, NA, 900, 1500) 가 있다. NA 를 제외한 평균과, NA 를 0 으로 채운 평균을 각각 구한다. 두 값이 다른 이유를 한 문장으로 쓴다.
  3. sizes <- c("대", "소", "중", "소") 를 소 < 중 < 대 순서를 가진 순서형 factor 로 만드는 코드를 쓴다.
  4. length(c(NA, NULL, 3)) 의 결과와, as.numeric(factor(c("10", "5", "10"))) 의 결과를 예측하고 이유를 설명한다.

정답과 해설

  1. (가) "integer", (나) "numeric", (다) "character" 다. (가)는 논리값 TRUE 가 정수 1 로 바뀌어 1L 과 2L 이 된다. (나)는 정수 1L 이 실수 1 로 바뀐다. (다)는 글자가 하나 있으므로 2.5 가 "2.5" 가 된다. 오른쪽에 있는 자료형으로 맞춰진다는 규칙을 적용하면 된다.
  2. NA 를 제외한 평균은 mean(prices, na.rm = TRUE) 로 1200 이다. (1200 + 900 + 1500) / 3 = 1200 이기 때문이다. NA 를 0 으로 채우려면 filled <- prices; filled[is.na(filled)] <- 0 으로 복사본을 만들고 mean(filled) 을 구하며, 결과는 900 이다. 나눗셈의 분모가 3 에서 4 로 늘어나고 분자는 그대로이기 때문이다. NA 를 제외하면 "모르는 칸을 계산에서 뺀 평균"이고, 0 으로 채우면 "그 칸이 0원이었다고 본 평균"이다.
  3. 다음과 같이 쓴다.
    sizes <- c("대", "소", "중", "소")
    sizes_f <- factor(sizes, levels = c("소", "중", "대"), ordered = TRUE)
    print(sizes_f)
    levels 를 지정하지 않으면 글자 순서로 수준이 만들어져 "대", "소", "중" 이 되므로 원하는 순서가 나오지 않는다. 출력은 Levels: 소 < 중 < 대 로 표시된다.
  4. length(c(NA, NULL, 3)) 은 2 다. NA 는 자리를 차지하지만 NULL 은 차지하지 않으므로 NA 와 3 만 남는다. as.numeric(factor(c("10", "5", "10"))) 은 1 2 1 이다. 수준이 글자 순서로 "10", "5" 가 되므로 "10" 은 1 번, "5" 는 2 번이 된다. 원래 숫자를 얻으려면 as.numeric(as.character(...)) 를 써야 한다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.