트랙
/
Julia
Julia
/
학습 과정
/
벡터 연산
벡터

벡터 연산 에서 Julia

2개의 연습 문제

벡터 연산 소개

Vectors 개념에서 우리는 "배열은 Julia 언어의 핵심에 있어요"라고 했고, 벡터는 1차원 배열이에요.

이렇게 보면, Julia 언어가 벡터로 무언가를 하는 다양하고 강력한 방법을 많이 제공하리라고 기대하는 게 당연하겠죠. 그게 무슨 뜻이든 간에요.

용어에 대한 참고 사항: 이 문서는 "벡터"에 대해 많이 이야기하지만, 내용 상당수는 범위, 튜플, 집합을 비롯한 여러 반복 가능 타입에도 똑같이 적용돼요.

벡터를 입력으로 받는 함수

아주 단순한 함수 중에는 벡터를 입력으로 받아서 (1차원 입력에 대해서는) 스칼라 값을 반환하는 것들이 있어요.

v = [2, 3, 4]
length(v)  # => 3
sum(v)  # => 9

다차원 배열 개념에 이르면, 이것이 반드시 스칼라를 반환하는 게 아니라 _차원 축소_라는 점이 더 분명해질 거예요. 지금은 잘 이해가 안 되더라도 걱정 말고 넘어가요.

이런 유형의 함수는 훨씬 더 많아요. 몇 가지 예는 Statistics 개념에서 볼 수 있어요.

여러 벡터를 대상으로 동작하는 함수도 있어요. (아주 유용한) zip이 그렇죠.

julia> z = zip( 1:3, ['a', 'b', 'c'], ["I", "make", "tuples"] )
zip([1, 2, 3], ['a', 'b', 'c'], ["I", "make", "tuples"])

# convert iterator to vector
julia> collect(z)
3-element Vector{Tuple{Int64, Char, String}}:
 (1, 'a', "I")
 (2, 'b', "make")
 (3, 'c', "tuples")

zip()은 벡터처럼 다룰 수 있는 입력을 임의의 개수만큼 받아서 튜플 이터레이터를 반환해요.

입력들은 보통 모두 길이가 같아요. 하나가 더 짧으면 나머지는 가장 짧은 길이에 맞춰 잘려요. 의도한 것일 수도 있지만, 대개는 코드에 있는 버그예요.

산술 연산

숫자 벡터가 있는데, 각 값에서 0.5를 빼고 싶다고 해봐요.

julia> v = [1.2, 1.5, 1.7]
3-element Vector{Float64}:
 1.2
 1.5
 1.7

julia> v - 0.5
ERROR: MethodError: no method matching -(::Vector{Float64}, ::Float64)

이건 실패하네요. 그럼 다른 벡터를 빼면 어떨까요?

julia> v - [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

성공은 하지만, 벡터가 길어질수록 꽤 번거롭고 메모리도 많이 차지해요.

실러버스에서 어디까지 나갔는지에 따라, 다른 방법들도 떠올릴 수 있을 거예요.

  • 루프를 작성하기: 다만 장황하고 투박할 거예요.
  • 컴프리헨션 사용하기: [x - 0.5 for x in v]가 원하는 결과를 줘요 (Python 스타일).
  • 고차 함수 사용하기: map(x -> x - 0.5, v)도 동작해요 (Haskell 스타일이지만, 많은 언어에서 흔히 쓰이는 방식이에요).

다행히 Julia에는 이 문제를 아주 간단히 해결해 주는 "마법"의 점이 있어요. v .- 0.5만 있으면 돼요.

이유는 다음 섹션에서 설명해요.

브로드캐스팅

그래서 v - 0.5는 실패하고 v .- 0.5는 성공해요. 그럼 이 점이 무슨 일을 하는지 이해해 봐야겠죠.

점은 두 가지 일을 하는데, 둘이 합쳐져서 원하는 결과가 나와요.

1) 요소별 적용

먼저, 중위 연산자 앞에 점을 붙이는 것은 "이 연산을 각 요소에 따로 적용하라"는 뜻이에요.

마찬가지로, 함수 이름 뒤에 점을 붙이면 그 함수가 "벡터화"돼요. 스칼라 입력을 위해 작성된 함수라도 마찬가지예요.

julia> sqrt.([1, 4, 9])
3-element Vector{Float64}:
 1.0
 2.0
 3.0

덧붙이자면, 중위 연산자는 사실 그 밑에 있는 함수를 위한 문법적 설탕일 뿐이에요.

예를 들어 [1, 5, 10] .% 3은 인터프리터가 mod.([1, 5, 10], 3)으로 변환하고, 그다음 mod. 문법이 실행돼요 (두 표현 모두 [1, 2, 1]을 반환해요).

2) 싱글턴 확장

앞의 예제에서 길이가 같은 벡터끼리 뺄 수 있다는 걸 봤어요. 다만 .-는 요소별 연산이라는 의도를 분명히 해 주는, -보다 더 안전한 연산자라는 점을 알아두세요.

julia> v .- [0.5, 0.5, 0.5]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

길이가 다른 벡터라면 어떨까요?

julia> v .- [0.5, 0.5]
ERROR: DimensionMismatch: arrays could not be broadcast to a common size

julia> v .- [0.5,]
3-element Vector{Float64}:
 0.7
 1.0
 1.2

일반적으로 길이가 다르면 오류예요. 단, 한쪽 길이가 1일 때는 예외예요 (엄밀히 말하면 "싱글턴" 차원이죠).

[0.5,]나 그냥 0.5 같은 싱글턴은 반복을 통해 필요한 길이로 자동 확장돼요. 이것이 broadcasting의 핵심이에요.

이 "반복" 때문에 메모리 사용량을 걱정하는 분은 안심해도 돼요. 실제로는 메모리에 값을 복사하지 않는 아주 효율적인 방식으로 구현돼 있거든요.

다른 언어의 브로드캐스팅에 익숙한 프로그래머라면, Julia의 방식이 NumPy와 (대체로) 비슷하지만 크기가 맞지 않는 경우를 R보다 훨씬 덜 너그럽게 받아들인다는 점을 알아두세요.

제자리 브로드캐스팅

메모리 사용량이 걱정된다면, 제자리 연산은 할당을 줄이는 흔한 방법이에요. 하지만 위 예제들의 브로드캐스팅 연산은 원본을 수정하는 대신 새로운 Vector를 만들어요.

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 1
 2
 3

v를 제자리에서 수정하려면, 수정 연산을 브로드캐스트해야 해요.

julia> v .= v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v .+= 1  # equivalent operation to above
3-element Vector{Int64}:
 3
 4
 5

julia> v
3-element Vector{Int64}:
 3
 4
 5

하지만 조심하세요! 대입 연산자 .= 앞의 점이 중요해요.

julia> v = [1, 2, 3];

julia> v = v .+ 1
3-element Vector{Int64}:
 2
 3
 4

julia> v
3-element Vector{Int64}:
 2
 3
 4

같은 방식으로 동작한 것처럼 보이지만, 여기서는 v .+ 1이 새 벡터 [2, 3, 4]를 만든 다음 그걸 변수 v에 대입한 거예요. 처음 벡터 [1, 2, 3]은 메모리에 남아 가비지 컬렉션 대상이 되죠. 결국 처음 벡터에 할당된 메모리를 재사용하는 앞의 예제보다 메모리를 두 배로 쓰게 돼요.

마찬가지로, 크기가 같은 벡터끼리 브로드캐스팅할 때도 관련된 미묘한 차이가 있어요. 예를 들어, 크기가 같은 두 벡터 v와 w가 있을 때를 봐요.

  • v .= w는 v의 메모리 위치에 w의 copy를 만들어요. 이후 v를 바꿔도 w에 영향을 주지 않고, 그 반대도 마찬가지예요.
  • v = w는 w의 메모리 위치를 가리키는 또 하나의 포인터를 v라는 이름으로 만들어요. 이후 v의 변경은 w에 반영되고, 그 반대도 마찬가지예요.

점 없는 연산자: 주의해야 할 이야기

이 소단원은 수학 비중이 꽤 커서, 대부분의 학습자가 완전히 이해할 필요는 없어요. 그래도 예상치 못한 오류 메시지를 만났을 때 디버깅에 도움이 될 수 있는 유용한 경고예요.

julia> v = [1, 2, 3]
3-element Vector{Int64}:
 1
 2
 3

julia> v * v
ERROR: MethodError: no method matching *(::Vector{Int64}, ::Vector{Int64})

# look, no commas
julia> u = [1 2 3]
1×3 Matrix{Int64}:
 1  2  3

julia> u * v
1-element Vector{Int64}:
 14

julia> v * u
3×3 Matrix{Int64}:
 1  2  3
 2  4  6
 3  6  9

선형대수 배경이 있다면, (1) 전형적인 Exercism 사용자는 아니지만 (여기서는 아주 환영해요!) , (2) v는 열 벡터, u는 행 벡터, u * v는 내적, v * u는 외적이라는 걸 알아차릴지도 몰라요. Julia는 이 경우에도, 모든 경우에도 수학의 규칙을 따라요.

그 외의 분들께: 요소별 계산에는 항상 점이 붙은 연산자를 쓰라고 권하는 이유만은 이해해 주세요. v .* v는 기대하는 그대로 동작해서 [1, 4, 9]를 줘요.

인덱싱

인덱스 번호로 벡터의 요소를 선택하는 방법은 앞선 개념들에서 다뤘어요.

a = collect('A':'Z')  # => 26-element Vector{Char}

# index with an integer
a[2]  # => 'B'

# index with a range
 a[12:2:18]  # => ['L', 'N', 'P, 'R']
 
 # index with another vector
 a[ [1, 3, 5] ]  # => ['A', 'C', 'E']

논리 인덱싱

어떤 논리식(엄밀히 말하면 "프레디킷")을 만족하는 요소를 선택할 수도 있어요. 이때는 보통 브로드캐스팅이 필요해요.

julia> a[a .< 'D']
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)

표현이 더 복잡해지면 점이 자꾸 늘어나요 (그래도 점은 작고 입력하기 쉬워요).

julia> a[a .< 'D' .|| a .> 'W']
6-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
 'X': ASCII/Unicode U+0058 (category Lu: Letter, uppercase)
 'Y': ASCII/Unicode U+0059 (category Lu: Letter, uppercase)
 'Z': ASCII/Unicode U+005A (category Lu: Letter, uppercase)

다시 말하지만, "벡터"는 사실 범위처럼 순서가 있는 적절한 반복 가능 타입이면 무엇이든 될 수 있어요.

julia> n = 3:10
3:10

julia> n[isodd.(n)]
4-element Vector{Int64}:
 3
 5
 7
 9

내부적으로 프레디킷은 BitVector로 변환된 뒤 인덱스로 사용돼요.

julia> condition = a .< 'D'
26-element BitVector:
 1
 1
 1
 0
 # display truncated

julia> a[condition]
3-element Vector{Char}:
 'A': ASCII/Unicode U+0041 (category Lu: Letter, uppercase)
 'B': ASCII/Unicode U+0042 (category Lu: Letter, uppercase)
 'C': ASCII/Unicode U+0043 (category Lu: Letter, uppercase)
GitHub에서 편집 링크가 새 창이나 탭에서 열려요

벡터 연산 배우기