트랙
/
Julia
Julia
/
연습 문제
/
치즈 클럽
치즈 클럽

치즈 클럽

학습 연습 문제

소개

엄밀히 말하면 Higher Order Function은 다음 중 하나 이상을 하는 함수일 뿐이에요.

  • 인자 중 하나로 함수를 받아요.
  • 결과로 함수를 반환해요.

함수형 프로그래밍의 세계에서는 쓰임새가 더 좁은 편이에요. 보통은 전달받은 함수를 컬렉션의 각 원소에 적용하는 filter, map, reduce 같은 함수를 가리켜요.

컬렉션 다루기

이 시점에서 우리는 Vector 같은 반복 가능한 컬렉션의 모든 원소에 연산을 적용하는 여러 방법을 이미 살펴봤어요.

  • 루프를 사용해요 (디지털 컴퓨팅이 시작된 이래 대부분의 프로그래밍 언어가 그렇듯이).
  • 컴프리헨션을 사용해요 (Python 스타일).
  • 브로드캐스팅을 사용해요 (Julia 특유의 문법이지만 R, Matlab, NumPy에 크게 빚지고 있어요).

이번 개념에서는 고차 함수에 집중해요 (Haskell이나 F# 같은 함수형 언어를 다뤄봤다면 익숙한 개념이에요).

다른 방법들도 있어요.

  • 재귀 (ML 계열 언어에서처럼).
    • Julia에서도 가능하지만, 꼬리 호출 최적화가 없으면 스택 오버플로가 날 위험이 있어요.
  • 매크로를 이용한 메타프로그래밍 (전통적으로 Lisp의 기능이에요).
    • 고급 Julia 프로그래밍에서 널리 쓰이지만, 대부분의 경우에는 조심해서 접근해요.
    • 다른 방법들이 보통 쓰기도 더 쉽고 디버깅은 훨씬 더 쉬워요.

필터링

filter() 함수는 불리언 값을 반환하는 함수를 인자로 받아 컬렉션에 적용해요. true를 반환하는 원소만 결과에 포함되고, 결과는 입력과 같은 기본 타입이에요 (아래를 참고해요).

julia> filter(iseven, 1:6)
3-element Vector{Int64}:
 2
 4
 6

# String is a collection of Chars, so String in -> String out
julia> filter(!isascii, "Hrōðgār")
"ōðā"

# tuple input -> tuple output
julia> filter(iseven, (1, 2, 3, 4, 5))
(2, 4)

다차원 배열에서 filter는 입력의 차원을 펼쳐서 Vector를 반환해요. 출력 타입이 입력 타입과 일치한다는 규칙에서 가장 큰 예외예요.

julia> m
2×3 Matrix{Int64}:
 1  2  3
 4  5  6

julia> filter(isodd, m)
3-element Vector{Int64}:
 1
 5
 3

위 예제는 내장 함수를 사용했지만, 이럴 때는 익명 함수를 쓰는 일이 아주 흔해요.

julia> filter(x -> x % 3 == 0, 1:20)
6-element Vector{Int64}:
  3
  6
  9
 12
 15
 18

이번 개념에 나오는 많은 함수들처럼 filter!()라는 제자리에서 동작하는 버전도 있어요.

매핑

map() 함수는 각 원소에 함수를 적용해서 컬렉션을 변환해요. 단순한 경우에는 출력의 모양이 입력과 같아서 브로드캐스팅과 비슷해 보일 수 있어요.

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

julia> map(x -> x^2 + 1, 1:4)
4-element Vector{Int64}:
  2
  5
 10
 17

julia> m
2×3 Matrix{Int64}:
 1  2  3
 4  5  6

julia> map(√, m)
2×3 Matrix{Float64}:
 1.0  1.41421  1.73205
 2.0  2.23607  2.44949

map()은 여러 컬렉션에 대해 원소별로 동작할 수도 있어요.

julia> map(*, [1, 2], [3, 4])
2-element Vector{Int64}:
 3
 8

개념적으로는 여러 입력 컬렉션에 zip()을 실행한 다음, 그 중간 결과의 각 원소에 map()을 실행하는 것과 같다고 생각할 수 있어요. 이건 어디까지나 대략적인 비유일 뿐이고, 구현 방식에 대해서는 아무것도 말해주지 않아요!

zip()과 마찬가지로, 모양이 맞지 않는 컬렉션은 가장 작은 쪽의 차원에 맞춰 잘려요.

데이터베이스에 쓰거나 배열에 push!하는 것처럼, 전달한 함수의 부수 효과만 필요한 경우도 있어요. 그럴 때는 항상 nothing을 반환하는 고차 함수 foreach()를 쓸 수 있어요.

리듀싱

reduce() 함수는 인자 두 개를 받는 함수를 컬렉션에 적용해서 차원을 줄여요.

추상적으로 들으면 헷갈릴 수 있지만, 컬렉션을 받아 값 하나를 반환하는 sum()이나 prod() 같은 함수를 생각해 봐요.

julia> sum(1:4) # add
10

julia> prod(1:4) # multiply
24

이런 특수 함수들은 고도로 최적화되어 있으니, 쓸 수 있을 때는 항상 이걸 써야 해요. 다른 예로는 maximum()과 minimum(), 논리 함수인 all()과 any(), 그리고 여러 통계 함수가 있어요.

설명을 위해서만 살펴보면, 같은 기능을 좀 더 일반적인 reduce()로 구현하면 이렇게 돼요 (중위 연산자 +와 *도 내부적으로는 사실 함수라는 걸 기억해 봐요).

julia> reduce(+, 1:4) # add
10

julia> reduce(*, 1:4) # multiply
24

sum()이나 다른 집계 함수들과 마찬가지로, reduce()는 줄일 차원을 지정하는 선택적 키워드 인자 dims를 받을 수 있어요.

julia> m
2×3 Matrix{Int64}:
 1  2  3
 4  5  6

julia> reduce(+, m; dims=1)
1×3 Matrix{Int64}:
 5  7  9

이 예제들이 쉬운 이유는 덧셈과 곱셈이 둘 다 교환 법칙(1+2 == 2+1)과 결합 법칙((1+2)+3 == 1+(2+3))이 성립하기 때문이에요.

하지만 이런 성질이 항상 성립하는 건 아니에요! 뺄셈과 나눗셈처럼 흔한 연산조차 결합 법칙이 성립하지 않아요.

게다가 큰 컬렉션에서는 부동소수점 오류가 쌓일 수 있어서, 왼쪽에서 오른쪽으로 reduce한 결과가 오른쪽에서 왼쪽으로 한 결과와 조금 다를 수 있다는 문제도 있어요.

Julia의 reduce 함수가 어느 방향으로 동작하는지는 구현에 따라 다르고, 보장되지도 않아요.

방향을 명시적으로 제어하려면 foldl()과 foldr() 함수를 쓰면 돼요. 이름상 각각 "왼쪽"과 "오른쪽"에서 시작하지만, Vector에서는 실제로는 위와 아래예요.

julia> foldl(-, 1:3) # (1 - 2) - 3
-4

julia> foldr(-, 1:3) # 1 - (2 - 3)
2

이 함수들은 1차원으로 다룰 수 있는 컬렉션을 위한 것이고, 스칼라 결과를 반환한다는 점을 기억해요. dims 인자는 reduce에서만 지원되고, foldl과 foldr에서는 지원되지 않아요.

MapReduce

여러 프로그래밍 분야에서 map 연산과 reduce를 결합하는 일은 아주 흔해요.

map을 실행한 다음 그 중간 컬렉션에 reduce를 실행할 수도 있어요. 하지만 이 방식은 아무리 잘 봐도 비효율적이고, 컬렉션이 커질수록 성능이 아주 나빠져요.

대신 결합된 mapreduce() 함수를 쓰는 걸 강력히 권장해요. 이 함수는 map과 reduce 연산을 교차로 수행하는, 훨씬 성능이 좋은 알고리즘을 구현할 수 있어요.

첫 번째 인자는 매핑할 함수이고, 두 번째 인자는 reduce 연산자예요.

julia> mapreduce(x -> x^2 + 1, +, 1:3)
17

# equivalent to (2 + 5 + 10)
julia> sum(map(x -> x^2 + 1, 1:3))
17

예상할 수 있듯이, Julia에는 방향이 중요한 상황을 위한 mapfoldl()과 mapfoldr() 함수도 있어요.

지침

우리는 치즈 동호회를 시작하려고 해요. 이 동호회는 머신러닝을 활용해, 치즈를 좋아하는 고객의 이력과 취향을 바탕으로 새로 선보일 치즈를 골라요.

새로 가입한 회원은 먼저 기본 설문에 답해야 해요. 그래야 시작할 때 쓸 기본 데이터를 어느 정도 모을 수 있어요. 이 과정에서 평가에 미묘함이 없는 극단적인 고객층이 있다는 걸 알게 됐어요. 이런 고객의 데이터는 더 섬세한 알고리즘에 돌이킬 수 없는 편향을 남길 수 있어서, 이들의 요구를 처리할 별도의 알고리즘을 마련해 두었어요. 이들의 데이터를 다룰 헬퍼 함수 몇 가지를 만들어 달라는 요청을 받았어요.

Note

다음 과제를 푸는 방법은 여러 가지겠지만, 각 과제는 서로 다른 고차 함수 하나씩으로 풀 수 있어요.

1. 고객 분류하기

평점 시스템은 별 다섯 개를 기준으로 하고, 정수 1:5로 이루어져 있어요. 극단적인 고객은 1점이나 5점만 주는데, 우리는 어떤 고객이 이런 행동을 보이는지 알고 싶어요.

평점 벡터를 받아서, 모든 평점이 1이거나 5이면 true를, 그렇지 않으면 false를 반환하는 all_15()를 구현해요.

julia> ratings = [2, 3, 4, 4, 1];

julia> all_15(ratings)
false

julia> ratings = [1, 5, 5, 1, 5];

julia> all_15(ratings)
true

2. 극단적인 고객 분리하기

더 극단적인 고객을 다른 고객과 분리해야 해요.

고객과 평점으로 이루어진 딕셔너리를 받아서, 1점이나 5점 평점만 사용하는 고객만 담은 비슷한 딕셔너리를 반환하는 emphatics()를 구현해요.

julia> ratings = ([2, 3, 5, 1, 1], [1, 1, 5, 5, 1], [4, 5, 5, 3, 2], [5, 5, 1, 1, 5]);

julia> names = ("c1", "c2", "c3", "c4");

julia> customers = Dict(zip(names, ratings))
Dict{String, Vector{Int64}} with 4 entries:
  "c2" => [1, 1, 5, 5, 1]
  "c1" => [2, 3, 5, 1, 1]
  "c3" => [4, 5, 5, 3, 2]
  "c4" => [5, 5, 1, 1, 5]

julia> emphatics(customers)
Dict{String, Vector{Int64}} with 2 entries:
  "c2" => [1, 1, 5, 5, 1]
  "c4" => [5, 5, 1, 1, 5]

3. 평점을 이진 값으로 바꾸기

극단적인 고객은 1점과 5점 평점만 사용하니, 이걸 0과 1로 바꾸면 계산하기가 더 편해져요.

극단적인 평점 벡터를 받아서 이진 평점을 반환하는 tobinary()를 구현해요. 여기서 1은 0으로, 5는 1로 바뀌어요.

julia> ratings = [1, 1, 5, 5, 1];

julia> tobinary(ratings)
5-element Vector{Int64}:
 0
 0
 1
 1
 0

4. 평점을 행렬로 만들기

우리 알고리즘은 Matrix 입력을 사용하니, 데이터를 행렬로 변환해야 해요.

극단적인 평점 벡터로 이루어진 벡터를 받아서, 변환된 데이터를 Matrix로 반환하는 tobinarymatrix()를 구현해요. 각 평점 벡터는 행렬의 행 하나가 돼요.

julia> customersratings = [[1, 1, 5, 5, 1],[5, 5, 1, 1, 5]];

julia> tobinarymatrix(customersratings)
2×5 Matrix{Int64}:
 0  0  1  1  0
 1  1  0  0  1
GitHub에서 편집 링크가 새 창이나 탭에서 열려요
Julia Exercism

치즈 클럽 문제를 시작해 볼 준비가 됐나요?

Exercism에 가입하고 Julia 트랙을 개념 35개연습 문제 128개, 그리고 실제 사람의 멘토링과 함께 배우고 익혀 보세요. 모두 무료예요.