누락 값
누락 값 (Missing Values)
데이터를 다루다 보면 "값이 없다"는 상황을 표현해야 할 때가 있어요. Julia는 통계적인 의미의 누락 값(missing value)을 나타내는 기능을 제공해요. 값이 '이론적으로는 존재해야 하지만 해당 관측에는 없다'는 상황을 표현하기 위한 거예요.
본문
누락 값은 missing 객체로 표현돼요. missing은 Missing 타입의 단 하나뿐인(singleton) 인스턴스예요. SQL의 NULL, R의 NA와 동급이며, 대부분의 상황에서 그들과 같은 식으로 동작해요.
누락 값의 전파 (Propagation of Missing Values)
missing 값은 표준 수학 연산자와 함수에 전달되면 **자동으로 전파(propagate)**돼요. 이런 함수에서는 피연산자 하나의 값에 대한 불확실성이 결과의 불확실성으로 이어지죠. 실제로 말하면, missing이 포함된 수학 연산은 대개 missing을 반환해요.
julia> missing + 1
missing
julia> "a" * missing
missing
julia> abs(missing)
missing
missing은 평범한 Julia 객체라서, 이런 전파 규칙은 그 동작을 구현하기로 "선택한" 함수에서만 작동해요. 이는 다음과 같은 방법으로 이뤄집니다.
Missing타입 인자를 받는 특정 메서드를 추가하거나,- 해당 타입의 인자를 받아들이고, 그 인자를 (표준 수학 연산자처럼) 전파하는 함수에 넘기는 방식.
패키지를 만들 때는 새 함수를 정의하면서 누락 값을 전파하는 게 말이 되는지 고민해 보고, 그렇다면 그에 맞는 메서드를 정의하는 게 좋아요. Missing 타입의 인자를 받는 메서드가 없는 함수에 missing 값을 넘기면, 다른 어떤 타입과 마찬가지로 MethodError가 발생해요.
누락 값을 전파하지 않는 함수는 Missings.jl 패키지가 제공하는 passmissing 함수로 감싸면 전파하도록 만들 수 있어요. 예를 들어 f(x)는 passmissing(f)(x)가 되죠.
동등·비교 연산자 (Equality and Comparison Operators)
표준 동등·비교 연산자는 앞서 본 전파 규칙을 그대로 따르는데, 피연산자 중 하나라도 missing이면 결과도 missing이 돼요. 몇 가지 예를 볼게요.
julia> missing == 1
missing
julia> missing == missing
missing
julia> missing < 1
missing
julia> 2 >= missing
missing
특히 missing == missing이 missing을 반환한다는 점을 눈여겨보세요. 그래서 ==로는 어떤 값이 missing인지 판별할 수 없어요. x가 missing인지 테스트하려면 ismissing(x)를 쓰면 됩니다.
특수 비교 연산자 isequal과 ===는 전파 규칙의 예외예요. missing이 있더라도 항상 Bool 값을 반환하죠. missing은 missing끼리는 같다고 보고, 다른 어떤 값과도 다르다고 봐요. 그래서 이 연산자들로 값이 missing인지 판별할 수 있어요.
julia> missing === 1
false
julia> isequal(missing, 1)
false
julia> missing === missing
true
julia> isequal(missing, missing)
true
isless 연산자도 또 하나의 예외예요. missing을 다른 어떤 값보다 크다고 취급하죠. sort!가 이 연산자를 사용하기 때문에, 정렬하면 missing 값들이 늘 맨 뒤로 밀려나요.
julia> isless(1, missing)
true
julia> isless(missing, Inf)
false
julia> isless(missing, missing)
false
논리 연산자 (Logical operators)
논리(불리언) 연산자 |, &, xor도 또 다른 특수한 경우예요. 이들은 논리적으로 필요할 때만 missing을 전파하거든요. 이 연산자에서 결과가 불확실한지 여부는 그 특정 연산에 따라 달라져요. 이는 SQL의 NULL이나 R의 NA가 구현하는 그 유명한 삼치 논리(three-valued logic) 규칙을 따르는 거예요. 이렇게 추상적인 정의는 사실 꽤 자연스러운 동작에 해당하는데, 구체적인 예시로 설명하는 게 제일 쉬워요.
논리 'or' 연산자 |로 이 원리를 살펴볼게요. 불리언 논리의 규칙에 따르면, 피연산자 중 하나가 true라면 나머지 피연산자의 값은 결과에 영향을 주지 못하고 결과는 항상 true예요.
julia> true | true
true
julia> true | false
true
julia> false | true
true
이 관찰을 바탕으로 추론하면, 피연산자 하나가 true이고 다른 하나가 missing이라면, 실제 값의 불확실성에도 불구하고 결과가 true임을 알 수 있어요. 두 번째 피연산자의 실제 값이 관측 가능했다면 그 값은 true거나 false 둘 중 하나였을 테고, 어느 쪽이어도 결과는 true였기 때문이죠. 따라서 이 특별한 경우에는 누락이 전파되지 않아요.
julia> true | missing
true
julia> missing | true
true
반대로 피연산자 하나가 false라면, 결과는 다른 피연산자의 값에 따라 true가 될 수도 false가 될 수도 있어요. 그러니 그 피연산자가 missing이라면 결과도 missing일 수밖에 없죠.
julia> false | true
true
julia> true | false
true
julia> false | false
false
julia> false | missing
missing
julia> missing | false
missing
논리 'and' 연산자 &의 동작은 |와 비슷한데, 차이는 피연산자 중 하나가 false일 때 누락이 전파되지 않는다는 점이에요. 예를 들어 첫 번째 피연산자가 false인 경우를 볼게요.
julia> false & false
false
julia> false & true
false
julia> false & missing
false
반면 피연산자 중 하나가 true(예: 첫 번째)면 누락이 전파돼요.
julia> true & true
true
julia> true & false
false
julia> true & missing
missing
마지막으로 '배타적 or' 논리 연산자 xor는 항상 missing을 전파하는데, 두 피연산자가 언제나 결과에 영향을 주기 때문이에요. 덧붙여 부정 연산자 !도 다른 단항 연산자처럼, 피연산자가 missing이면 missing을 반환해요.
제어 흐름과 단락 연산자 (Control Flow and Short-Circuiting Operators)
if, while, 그리고 삼항 연산자 x ? y : z 같은 제어 흐름 연산자는 missing 값을 허용하지 않아요. 실제 값을 관측할 수 있다면 true인지 false인지 모르기 때문이죠. 그러면 프로그램이 어떻게 동작해야 할지 알 수 없어요. 이런 맥락에서 missing 값을 만나면 즉시 TypeError가 발생해요.
julia> if missing
println("here")
end
ERROR: TypeError: non-boolean (Missing) used in boolean context
같은 이유로, 앞서 본 논리 연산자와 달리 단락(short-circuiting) 불리언 연산자 &&와 ||도, 다음 피연산자를 평가할지 여부가 그 피연산자의 값에 달려 있는 상황에서는 missing을 허용하지 않아요. 예를 들면:
julia> missing || false
ERROR: TypeError: non-boolean (Missing) used in boolean context
julia> missing && false
ERROR: TypeError: non-boolean (Missing) used in boolean context
julia> true && missing && false
ERROR: TypeError: non-boolean (Missing) used in boolean context
반대로, missing 없이도 결과를 정할 수 있으면 오류가 발생하지 않아요. missing 피연산자를 평가하기 전에 코드가 단락되는 경우, 그리고 missing 피연산자가 맨 마지막에 있는 경우가 그렇죠.
julia> true && missing
missing
julia> false && missing
false
누락 값을 가진 배열 (Arrays With Missing Values)
누락 값을 가진 배열은 다른 배열처럼 만들 수 있어요.
julia> [1, missing]
2-element Vector{Union{Missing, Int64}}:
1
missing
이 예시가 보여주듯, 그런 배열의 요소 타입은 Union{Missing, T}인데, 여기서 T는 missing이 아닌 값들의 타입이에요. 배열의 각 원소가 T(여기선 Int64) 타입이거나 Missing 타입일 수 있다는 사실을 반영한 거죠. 이런 배열은 실제 값들을 담는 Array{T}와, 각 원소가 Missing인지 T인지를 알려주는 Array{UInt8}을 합친, 메모리 효율적인 저장 방식을 사용해요.
누락 값을 허용하는 배열도 표준 문법으로 만들 수 있어요. Array{Union{Missing, T}}(missing, dims)를 쓰면 missing으로 채워진 배열을 만들 수 있죠.
julia> Array{Union{Missing, String}}(missing, 2, 3)
2×3 Matrix{Union{Missing, String}}:
missing missing missing
missing missing missing
참고:
undef나similar를 쓰면 지금은missing으로 채워진 배열이 나올 수 있지만, 그건 올바른 방법이 아니에요. 위처럼missing생성자를 쓰세요.
missing 원소를 허용하는 요소 타입(예: Vector{Union{Missing, T}})을 가졌지만 실제로는 missing 원소를 하나도 안 담긴 배열은, convert로 missing을 허용하지 않는 타입(예: Vector{T})으로 변환할 수 있어요. 배열에 missing 값이 있으면 변환 중에 MethodError가 발생하죠.
julia> x = Union{Missing, String}["a", "b"]
2-element Vector{Union{Missing, String}}:
"a"
"b"
julia> convert(Array{String}, x)
2-element Vector{String}:
"a"
"b"
julia> y = Union{Missing, String}[missing, "b"]
2-element Vector{Union{Missing, String}}:
missing
"b"
julia> convert(Array{String}, y)
ERROR: MethodError: Cannot `convert` an object of type Missing to an object of type String
누락 값 건너뛰기 (Skipping Missing Values)
missing 값이 표준 수학 연산자로 전파되기 때문에, 누락 값을 담은 배열에 축소(reduction) 함수를 적용하면 missing을 반환해요.
julia> sum([1, missing])
missing
이런 상황에서는 skipmissing 함수로 누락 값을 건너뛰면 돼요.
julia> sum(skipmissing([1, missing]))
1
이 편의 함수는 missing 값을 효율적으로 걸러내는 이터레이터(iterator)를 반환해요. 그래서 이터레이터를 지원하는 어떤 함수와도 함께 쓸 수 있죠.
julia> x = skipmissing([3, missing, 2, 1])
skipmissing(Union{Missing, Int64}[3, missing, 2, 1])
julia> maximum(x)
3
julia> sum(x)
6
julia> mapreduce(sqrt, +, x)
4.146264369941973
배열에 skipmissing를 호출해 만든 객체는 원래 배열(부모 배열)의 인덱스로 인덱싱할 수 있어요. 누락 값에 해당하는 인덱스는 이런 객체에 유효하지 않아서, 그걸 쓰려 하면 오류가 발생해요(keys와 eachindex에서도 건너뛰어집니다).
julia> x[1]
3
julia> x[2]
ERROR: MissingException: the value at index (2,) is missing
[...]
이 덕분에 인덱스 위에서 동작하는 함수들이 skipmissing와 함께 잘 작동해요. 특히 검색·탐색(find) 함수가 그렇죠. 이런 함수들은 skipmissing가 반환한 객체에 유효한 인덱스를 반환하는데, 그것은 곧 부모 배열에서 일치하는 항목들의 인덱스이기도 해요.
julia> findall(==(1), x)
1-element Vector{Int64}:
4
julia> findfirst(!iszero, x)
1
julia> argmax(x)
1
collect를 사용하면 missing이 아닌 값들을 뽑아 배열로 저장할 수 있어요.
julia> collect(x)
3-element Vector{Int64}:
3
2
1
배열에 대한 논리 연산 (Logical Operations on Arrays)
논리 연산자에 대해 앞서 설명한 삼치 논리는 배열에 적용하는 논리 함수들에서도 똑같이 쓰여요. 그래서 == 연산자를 이용한 배열 동등성 검사도, missing 원소의 실제 값을 모르면 결과를 정할 수 없을 때 missing을 반환해요. 실제로 말하면, 비교하는 배열들의 missing이 아닌 값들이 모두 같지만 한쪽 또는 양쪽 배열에 missing 값이 있으면(아마 위치도 다를 수 있는), missing이 반환된다는 뜻이에요.
julia> [1, missing] == [2, missing]
false
julia> [1, missing] == [1, missing]
missing
julia> [1, 2, missing] == [1, missing, 2]
missing
단일 값의 경우처럼, isequal을 쓰면 missing 값을 다른 missing 값과는 같다고, 하지만 missing이 아닌 값과는 다르다고 취급할 수 있어요.
julia> isequal([1, missing], [1, missing])
true
julia> isequal([1, 2, missing], [1, missing, 2])
false
any와 all 함수도 삼치 논리의 규칙을 따릅니다. 그래서 결과를 정할 수 없으면 missing을 반환하죠.
julia> all([true, missing])
missing
julia> all([false, missing])
false
julia> any([true, missing])
true
julia> any([false, missing])
missing
더 알아보기
- Julia의
Missing타입 문서 - 데이터프레임에서 누락 값을 다루는 DataFrames 패키지
- 통계 분석과 관련된 배열 처리 문서