본문으로 건너뛰기

Typescript로 확률 Monad 구현하기 - 2

· 약 12분
Jake Son

이전 포스트에서 이어지는 내용입니다.

이항확률분포

Applicative Functor를 활용하면 이항확률분포를 계산할 수 있다.
이항확률분포란 동전 던지기와 같이 독립적인 시행의 결과가 성공 또는 실패 두 가지로만 이루어진 경우의 확률분포를 말한다.

앞면이 나올 확률이 0.3인 동전을 10번 던졌을 때 앞면이 나오는 횟수의 확률분포를 계산해보자.
먼저 하나의 시행에 대한 확률분포를 만드는데 확률변수는 0(뒷면), 1(앞면)으로 설정한다.

const dist = Distributions.of([
[0, 0.7],
[1, 0.3],
]);

각 시행은 독립적이므로 위 확률분포를 10개 생성한 후 결합하면 원하는 이항확률분포를 얻을 수 있다.

const dists = Array.from({ length: 10 }, () =>
Distributions.of([
[0, 0.7],
[1, 0.3],
]),
);

우선 확률변수 간의 결합로직을 생각해보자.
우리가 원하는 확률변수는 앞면이 나오는 횟수이고 앞면이 나오는 경우를 1로 설정했기 때문에 모든 확률변수를 더하면 된다.
따라서 number 타입의 인자 10개를 받아 모두 더한 값을 반환하는 함수를 만들면 된다.

위와 같은 함수를 join으로 바꾸어주는 liftA10과 같은 함수를 만드는 대신, 이전에 만들어 둔 liftA2를 사용할 수 있다.
앞서 정의한 dists는 배열이기 때문에 reduce를 사용할 수 있고 이를 활용하면 두 개의 확률분포를 하나로 만들어 나가는 과정을 구현할 수 있다.

const sum = (a: number, b: number) => a + b;
const result = dists.reduce(liftA2(sum));

console.log(result.value.map(([a, b]) => [a, +b.toFixed(5)]));
/*
[0, 0.02825],
[1, 0.12106],
[2, 0.23347],
[3, 0.26683],
[4, 0.20012],
[5, 0.10292],
[6, 0.03676],
[7, 0.009],
[8, 0.00145],
[9, 0.00014],
[10, 0.00001],
*/

사실 이항확률분포를 계산하기 위해 위와 같은 방법을 사용하기 보다 공식을 활용하는게 더 효율적이다.
하지만 이와 같은 방식은 단순히 공식을 적용하는 거 보다 더 직관적이며 이해하기 쉽다는 장점이 있다.

확률분포 Monad

Distributions가 Functor, Applicative Functor를 만족하게 만들었으니 이제 Monad를 만족하게 만들어보자.
Monad를 만족하려면 확률변수 A를 인자로 받고 확률분포 B를 반환하는 함수확률분포간의 변환함수로 만드는 chain 함수를 구현해야 한다.

chain이라는 이름은 다른 함수형 언어에서는 bind, flatMap 등으로 불리는데, fp-ts에서 사용하는 이름을 선택했다.

declare function chain<A, B>(
f: (a: A) => Distribution<B>,
): (dist: Distribution<A>) => Distribution<B>;

구현은 다음과 같다.

export const chain =
<A, B>(f: (a: A) => Distributions<B>) =>
(fa: Distributions<A>): Distributions<B> =>
Distributions.of(
fa.value.flatMap(
([variable, prob1]) =>
f(variable).value.map(([b, prob2]) => [b, prob1 * prob2]) as [
B,
Probability,
][],
),
);

구현로직은 이전에 구현한 ap와 비슷하다.
주어진 fa내부를 순회하면서 각 확률변수를 f에 적용하며, 그 반환값의 내부를 다시 순회하면서 fa의 확률과 곱해준다.

위 함수를 사용하면 앞서 정의한 map, ap에서는 적용할 수 없었던 독립적이지 않은 시행에 대한 확률분포를 계산할 수 있다.
예를 들면 다음과 같은 상황을 생각해보자.

  • 먼저 공평한 주사위를 던진다.
  • 만약 6이 나온경우 공평한 동전을 던지며, 6이 아닌 경우 앞면이 나올 확률이 0.1인 동전을 던진다.
  • 동전의 면(face)에 대한 확률분포를 구하라.

위와 같은 상황에서는 동전을 던지는 시행이 이전 시행의 결과에 의존적이라고 할 수 있다.
이제 chain을 사용하여 확률분포를 계산해보자.

const dice = Distributions.uniform([
Dice.One,
Dice.Two,
Dice.Three,
Dice.Four,
Dice.Five,
Dice.Six,
]);
const fairCoin = Distributions.uniform([Coin.Head, Coin.Tail]);
const unfairCoin = Distributions.of([
[Coin.Head, 0.1],
[Coin.Tail, 0.9],
]);

const result = pipe(
dice,
// 주사위의 결과가 6인 경우 공평한 동전을 던지고, 아닌 경우 불공평한 동전을 던진다.
chain((n) => (n === Dice.Six ? coin : unfairCoin)),
);

console.log(result.value); // [['Head', 1 / 6], ['Tail', 5 / 6]]

위 결과가 정확한지 검증하기 위해 확률공식을 통해 계산해보자.

(앞면이 나올 확률)
= (주사위가 6이 나올 확률) x (공평한 동전을 던졌을 때 앞면이 나올 확률)
+ (주사위가 6이 아닐 확률) x (불공평한 동전을 던졌을 때 앞면이 나올 확률)
= 1/6 x 1/2 + 5/6 x 1/10
= 1/12 + 1/12
= 1/6

코드를 보면 어디에도 확률을 계산하는 로직이 존재하지 않고 주사위를 던지고 그 결과에 따라 동전을 던지는 로직만 선언적으로 작성했다는 것을 알 수 있다.
이처럼 Monad를 활용하면 복잡하거나 번거로운 로직은 신경쓰지 않고 오로지 핵심로직만 집중할 수 있다는 장점이 있다고 생각한다.

조건부확률

이제 확률분포 Monad 구현을 완료했으니 다른 확률개념을 구현해보자.
기초 통계학을 배우게 되면 자주 등장하는 개념이 조건부확률이다.
Distributions 클래스도 조건부확률을 고려한 새로운 확률분포를 만드는 conditional 메서드를 구현할 수 있다.

conditional(event: Event<RANDOM_VARIABLE>): Distributions<RANDOM_VARIABLE> {
return Distributions.of(
this.#value.filter(([variable, _]) => event(variable))
);
}

구현 자체는 간단하며 인자로 주어진 사건을 기준으로 확률변수를 필터링 한 후 다시 정규화(of 메소드에서 수행)를 해주면 된다.
이제 다음과 같은 문제를 풀어보자.

주사위를 연속으로 두 번 던지는 상황을 가정해보자.
만약 두 주사위의 결과의 합이 5 이하였다면 첫 번째 주사위의 결과가 2일 확률은?

위 문제는 두 주사위의 결과가 5 이하라는 조건하에 첫 번째 주사위의 결과에 대한 확률을 구하는 것으로 코드로 표현하면 다음과 같다.

const join = liftA2((a: Dice, b: Dice) => [a, b] as const);
const twoDice = join(dice, dice);

const result = twoDice
.conditional(([first, second]) => first + second <= 5)
.evaluate(([first]) => first === Dice.Two);

console.log(result.toFixed(1)); // "0.3"

먼저 주사위에 대한 확률분포 dice 두 개를 결합하는데, 각 시행을 구분하기 위해 확률변수를 tuple로 묶어주었다.
이후 conditional메서드를 통해 두 주사위의 합이 5 이하인 경우만 필터링하고, evaluate메서드를 통해 첫 번째 주사위의 결과가 2인 경우의 확률을 계산했다.
이번에도 확률에 대한 계산로직은 작성하지 않고 주어진 조건에 대한 표현만 존재하는 것을 알 수 있다.

베이즈 정리

조건부확률과 함께 등장하는 개념으로 베이즈 정리가 있다.
위 정리를 활용해 보통 다음과 비슷한 문제를 풀었던 경험이 있을 것이다.

A라는 질병에 걸렸을 때, 그것을 진단하는 검사의 정확도가 95%라고 한다.
전 세계에서 A라는 질병에 걸린 사람은 1%라고 한다.
이 검사를 통해 A라는 질병에 걸렸다는 결과가 나왔다.
이때, 실제로 A라는 질병에 걸렸을 확률은 얼마일까?

지금까지 구현한 것들을 활용하면 공식없이 풀 수 있다.

// 질병에 대한 확률분포
const diseaseDist = Distributions.of([
[true, 0.01],
[false, 0.99],
]);
// 질병이 걸린 사람에 대한 검사결과의 확률분포
const positiveTest = Distributions.of([
[true, 0.95],
[false, 0.05],
]);
// 질병이 걸리지 않은 사람에 대한 검사결과의 확률분포
const negativeTest = Distributions.of([
[true, 0.05],
[false, 0.95],
]);

const result = pipe(
diseaseDist,
chain((disease) =>
pipe(
// 질병여부에 따라 다른 확률분포를 적용
disease ? positiveTest : negativeTest,
// 질병여부와 검사결과를 tuple로 묶어줌
map((test) => [disease, test] as const),
),
),
// 검사결과가 양성인 경우를 가정
(dist) => dist.conditional(([_, test]) => test),
// 실제로 발병한 경우의 확률을 계산
(dist) => dist.evaluate(([disease]) => disease),
);

console(result.toFixed(2)); // "0.16"

먼저 질병과 검사결과에 대한 확률분포를 정의하고, chain메서드를 통해 질병여부에 따라 다른 확률분포를 적용한다.
이후 두 확률변수를 map을 활용해 tuple로 묶어주고 조건부확률과 원하는 확률을 계산한다.