R · 기본
데이터로 시작하는 R
자료형과 결측값 - NA·NULL·factor
numeric·integer·character·logical, 자동 형 변환, NA 와 na.rm, factor 와 수준
개발자KR · 원고 갱신
이 장에서 배우는 것
앞 장에서는 값을 한 줄로 늘어놓은 벡터를 만들고 다뤘다. 이 장에서는 그 벡터에 들어 있는 값이 어떤 종류인지, 그리고 값이 비어 있을 때 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 와 구별해 두면 충분하다.
| 값 | length() | 뜻 | sum(c(5, 값)) 결과 |
|---|---|---|---|
| NA | 1 | 값이 있어야 하나 모른다 | NA |
| NULL | 0 | 아무것도 없다 | 5 |
| 0 | 1 | 값이 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(값, 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 |
|---|---|---|
| 수준을 정해 factor 만들기 | factor(x, levels = v) | forcats::fct(x, levels = v) |
| 쓰지 않는 수준 버리기 | droplevels(f) | forcats::fct_drop(f) |
| NA 를 0 으로 채우기 | x[is.na(x)] <- 0 | tidyr::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 | 빠진 개수를 먼저 확인한다 |
| NULL | is.null(), length() | 길이 0, 자리를 차지하지 않는다 |
| factor | factor(), levels(), droplevels() | 안쪽은 번호, 수준 표는 값에 없는 분류도 가진다 |
| factor 를 숫자로 | as.numeric(as.character(f)) | as.numeric(f) 는 번호를 돌려준다 |
연습 문제
- 다음 세 식의 결과를 실행하기 전에 예측하고, class() 로 확인한다. (가) c(TRUE, 2L) (나) c(1L, 2.5) (다) c(2.5, "a")
- prices <- c(1200, NA, 900, 1500) 가 있다. NA 를 제외한 평균과, NA 를 0 으로 채운 평균을 각각 구한다. 두 값이 다른 이유를 한 문장으로 쓴다.
- sizes <- c("대", "소", "중", "소") 를 소 < 중 < 대 순서를 가진 순서형 factor 로 만드는 코드를 쓴다.
- length(c(NA, NULL, 3)) 의 결과와, as.numeric(factor(c("10", "5", "10"))) 의 결과를 예측하고 이유를 설명한다.
정답과 해설
- (가) "integer", (나) "numeric", (다) "character" 다. (가)는 논리값 TRUE 가 정수 1 로 바뀌어 1L 과 2L 이 된다. (나)는 정수 1L 이 실수 1 로 바뀐다. (다)는 글자가 하나 있으므로 2.5 가 "2.5" 가 된다. 오른쪽에 있는 자료형으로 맞춰진다는 규칙을 적용하면 된다.
- 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원이었다고 본 평균"이다.
- 다음과 같이 쓴다.
levels 를 지정하지 않으면 글자 순서로 수준이 만들어져 "대", "소", "중" 이 되므로 원하는 순서가 나오지 않는다. 출력은 Levels: 소 < 중 < 대 로 표시된다.sizes <- c("대", "소", "중", "소") sizes_f <- factor(sizes, levels = c("소", "중", "대"), ordered = TRUE) print(sizes_f) - 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(...)) 를 써야 한다.
READER FEEDBACK
질문·의견
내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.
댓글 0
아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.