Devin.KR

확률 - 충돌·재시도·A/B 비교의 바탕

개발자KR 조회 1

이 장에서 배우는 것

앞 장에서 가능한 경우를 세었다면, 이제는 그 경우가 얼마나 자주 나타나는지 다룬다. 같은 수의 경우라도 발생 가능성이 다르면 확률은 달라진다. 요청 열 개 중 두 개가 실패했다는 관찰과 다음 요청이 실패할 확률이 0.2라는 가정도 구별해야 한다. 관찰은 이미 얻은 데이터이며, 가정은 아직 보지 못한 결과를 계산하기 위한 출발점이다.

이 장에서는 작은 경우를 직접 나열하고, 가정을 바꾸었을 때 계산이 어떻게 달라지는지 확인한다. 이어서 임시 ID의 충돌과 요청 재시도를 계산한다. 마지막에는 난수의 시작값을 고정한 모의실험을 만들어 계산값과 비교한다.

  • 가능한 결과 전체와 관심 있는 결과를 구분하여 확률을 계산한다.
  • 조건부 확률과 독립의 뜻을 작은 데이터로 확인한다.
  • 생일 문제의 계산을 ID 충돌 문제에 적용한다.
  • 기댓값으로 재시도 횟수와 비용을 예상한다.
  • 계산과 모의실험이 가까워지는 이유와, 그것만으로 확인할 수 없는 것을 구별한다.

문제 상황

파일 업로드 기능에 짧은 임시 ID를 붙이는 서비스를 만든다고 하자. 개발 중에는 ID를 만들 때마다 값이 달라 보인다. 그러나 운영 중에 수천 개를 만들면 두 파일에 같은 ID가 붙을 수 있다. “가능한 ID가 파일 수보다 훨씬 많다”는 설명만으로 충돌 가능성이 작다고 결론 내릴 수 있을까.

업로드 요청이 실패하면 재시도하는 기능도 필요하다. 한 번의 요청이 성공할 확률을 0.75로 가정하고 최대 세 번 시도한다면, 최종 성공 확률은 얼마인가. 요청 하나당 평균 몇 번의 시도가 필요한지도 알아야 처리량을 계획할 수 있다.

여기에 화면 A와 화면 B의 업로드 완료 비율을 비교하는 일이 더해진다. 화면 B의 완료 비율이 높더라도, 두 화면을 사용한 사람들의 기기나 네트워크 구성이 다르면 화면 변경의 효과라고 바로 말하기 어렵다. 확률은 숫자를 계산하는 도구이면서, 무엇을 같은 조건으로 비교했는지 점검하는 언어다.

이 장의 계산은 명시한 가정 안에서만 성립한다. ID는 가능한 값 가운데 하나를 같은 확률로 고르고, 각 선택은 이전 선택의 영향을 받지 않는다고 가정한다. 재시도도 매번 성공 확률이 같고 앞선 실패의 영향을 받지 않는다고 가정한다. 실제 시스템에 이 가정을 옮길 때 무엇을 살펴야 하는지도 함께 다룬다.

가능한 결과, 조건, 독립

표본 공간과 사건을 코드로 만들기

한 번의 실험에서 나올 수 있는 결과 전체를 표본 공간(sample space)이라고 한다. 여기서 실험은 실험실 작업만 뜻하지 않는다. 요청 하나를 고르거나 ID 하나를 생성하는 일도 포함한다. 그중 관심 있는 결과들의 모음을 사건(event)이라고 한다. 전공 수업에서는 보통 표본 공간을 Ω, 사건을 A나 B로 나타낸다. 이 장에서는 코드와 연결하기 쉽게 단어로 적는다.

다음은 기록 네 개 중 하나를 같은 확률로 고르는 실험이다. 각 기록은 기기 종류와 완료 여부로 이루어진다. 괄호로 값을 묶은 튜플은 여러 값을 한 항목으로 보관하는 Python의 자료형이다. 여기서는 기록을 집합의 원소로 넣기 위해 사용한다.

space = {
    ("mobile", "done"),
    ("mobile", "left"),
    ("desktop", "done"),
    ("desktop", "left"),
}
mobile = {row for row in space if row[0] == "mobile"}
done = {row for row in space if row[1] == "done"}

print(len(done) / len(space))
print(len(mobile & done) / len(space))

출력은 차례로 0.5와 0.25다. 중괄호 안에서 조건을 만족하는 항목을 모으는 표기는 집합을 만드는 짧은 반복문이다. 두 집합 사이의 &는 양쪽에 모두 들어 있는 원소를 구한다.

각 결과가 같은 확률일 때 P(A) = 사건 A의 원소 수 / 표본 공간의 원소 수로 계산한다. P는 확률을 나타내는 표기다. 이 식의 핵심은 “같은 확률”이라는 조건이다. 실제 접속자가 모바일에 더 많다면 네 가지 상태를 같은 비중으로 세면 안 된다.

표본 공간을 정할 때 함께 정해야 하는 것
질문이번 예제의 답확인할 점
무엇을 한 번 고르는가기록 네 개 중 하나사람, 요청, 상태를 섞지 않는다
가능한 결과는 무엇인가기기와 완료 여부의 조합필요한 결과를 빠뜨리지 않는다
각 결과의 확률은 같은가같다고 가정한다상태 종류의 수와 발생 빈도는 다르다
어떤 사건을 보는가완료, 모바일, 두 조건의 동시 충족분자와 분모의 대상을 분명히 한다

중복 기록을 집합에 넣으면 같은 기록이 하나로 합쳐진다. 따라서 이 예제는 네 가지 결과를 정의하는 용도다. 실제 기록의 빈도를 세려면 뒤에서 사용할 리스트처럼 중복을 보존하는 자료형이 필요하다.

조건부 확률은 분모를 바꾸는 일이다

모바일에서 완료할 확률을 알고 싶다면 모바일 기록만 남긴 뒤 완료 비율을 구한다. 이미 조건 B가 주어졌을 때 사건 A가 일어날 확률을 조건부 확률(conditional probability)이라고 한다. 전공 수업의 표기 P(A | B)는 “B라는 조건에서 A의 확률”이라고 읽는다. 세로줄은 나눗셈 기호가 아니다.

P(A | B) = P(A와 B가 함께 일어남) / P(B)다. 단, P(B)는 0보다 커야 한다. 남은 대상이 하나도 없으면 그 안에서의 비율은 정해지지 않는다.

모바일이라는 조건을 주면 전체 네 기록이 아니라 모바일 두 기록을 분모로 사용한다
conditional = len(mobile & done) / len(mobile)
print(conditional)

결과는 0.5다. 전체에서 모바일이면서 완료인 비율 0.25와 다르다. “모바일이며 완료”와 “모바일 중 완료”는 서로 다른 질문이다.

독립은 조건을 알아도 확률이 바뀌지 않는다는 뜻이다

사건 B가 일어났다는 정보를 알아도 사건 A의 확률이 바뀌지 않으면 두 사건은 독립(independence)이다. 전공 수업에서는 P(A와 B가 함께 일어남) = P(A) × P(B)라는 식으로도 정의한다. 이 식은 조건의 확률이 0인 경우까지 포함하여 사용할 수 있다.

앞의 네 기록에서는 완료 확률이 0.5이고 모바일 중 완료 확률도 0.5다. 두 사건의 동시 확률 0.25도 0.5 × 0.5와 같다. 따라서 이 표본 공간에서 모바일과 완료는 독립이다.

p_mobile = len(mobile) / len(space)
p_done = len(done) / len(space)
p_both = len(mobile & done) / len(space)
print(p_both == p_mobile * p_done)

출력은 True다. 이번 숫자는 이진수로 정확히 표현되는 값이라 직접 비교해도 된다. 일반적인 소수 계산은 반올림 오차를 고려해야 한다.

이제 완료된 모바일 기록 하나와 이탈한 데스크톱 기록 하나만 남겨 보자. 전체 완료 비율은 여전히 0.5지만 모바일 중 완료 비율은 1이다. 전체 완료 비율만 같다고 독립인 것은 아니다. 완성 코드에서는 이 작은 반례도 확인한다.

또한 “동시에 일어나지 않는다”와 독립은 다르다. 한 요청의 성공과 실패는 동시에 일어나지 않는다. 성공을 알면 실패 확률이 0이 되므로, 둘 다 발생 가능성이 있는 상황에서 두 사건은 독립이 아니다.

생일 문제를 ID 충돌로 바꾸기

여러 사람이 있을 때 같은 생일을 가진 사람이 한 쌍이라도 있을 확률을 묻는 문제가 생일 문제(birthday problem)다. 여기서는 윤일을 제외하고 날짜 365개가 같은 확률로 나오며 사람들의 생일이 서로 독립이라는 단순한 모형을 사용한다. 실제 생일 분포를 설명하려는 목적은 아니다.

ID 문제도 구조가 같다. 가능한 ID가 m개이고 복원을 허용해 n번 뽑는다고 하자. 복원을 허용한다는 말은 이미 나온 값도 다시 뽑을 수 있다는 뜻이다. 충돌은 같은 값이 두 번 이상 나온 사건이다.

충돌이 생기는 모든 경우를 따로 세기보다 충돌이 없는 경우를 계산하는 편이 쉽다. 관심 사건이 일어나지 않는 사건을 여사건(complement)이라고 한다. 둘의 확률을 더하면 1이다.

앞선 선택이 모두 달랐다는 조건에서 다음 선택도 다를 확률
선택 순서피해야 하는 값의 수새 값을 고를 확률
첫 번째0m / m
두 번째1(m - 1) / m
세 번째2(m - 2) / m
n번째n - 1(m - n + 1) / m

따라서 충돌 없음의 확률은 1 × (1 - 1/m) × (1 - 2/m) × … × (1 - (n - 1)/m)다. 충돌 확률은 1에서 이 값을 뺀다. 이 곱셈은 각 단계에 붙은 조건을 이어 붙인 것이다. “각 ID 생성이 독립이다”와 “중복을 피하는 사건들의 조건부 확률이 달라진다”는 설명은 함께 성립한다.

날짜 365개에서 23번 선택하면 충돌 확률은 약 0.507297이다. 23/365로 계산하면 안 된다. 그 나눗셈은 23개의 서로 다른 날짜를 미리 정하고 한 번의 선택이 그 안에 들어갈 확률 같은 다른 질문에 어울린다. 충돌에서는 새 값이 앞선 모든 값과 비교된다.

새 ID는 앞서 나온 모든 ID와 비교되므로 선택이 늘수록 피해야 할 값도 늘어난다

n이 m보다 크면 앞 장에서 살펴본 비둘기집 원리에 따라 충돌 확률은 1이다. n이 0이나 1이면 비교할 쌍이 없어 충돌 확률은 0이다. 완성 코드에서는 이 경계도 확인한다.

실제 ID 설계에서는 가능한 값의 수뿐 아니라 생성 규칙도 살펴야 한다. 같은 값으로 자주 쏠리는 생성기는 균등한 선택이라는 가정을 어긴다. 수학적으로 충돌 가능성이 작다는 계산은 저장소의 중복 검사와 충돌 시 처리 규칙을 대신하지 않는다.

기댓값으로 재시도 비용 예상하기

결과에 따라 정해지는 숫자를 확률변수(random variable)라고 한다. 재시도에서는 최종 성공 여부를 0 또는 1로 나타내거나, 실제 시도 횟수를 1, 2, 3으로 나타낼 수 있다. 전공 수업에서는 이런 숫자에 X 같은 이름을 붙인다.

기댓값(expected value)은 가능한 값마다 그 값이 나올 확률을 곱해서 더한 값이다. E(X) = 각 값 × 그 값의 확률을 모두 더한 결과다. 같은 조건의 실험을 많이 반복할 때 평균을 예상하는 기준이다. 한 번의 실행에서 반드시 나오는 값이라는 뜻은 아니다.

한 번의 성공 확률을 p = 0.75라고 하자. 실패 확률 q는 1 - p = 0.25다. 최대 세 번 시도하되 성공하면 바로 멈춘다. 모두 실패할 확률은 q × q × q = 0.015625이므로 최종 성공 확률은 0.984375다. 여기서는 실패들이 독립이라는 가정이 필요하다.

성공하면 멈추는 재시도의 실제 시도 횟수
시도 횟수그 횟수에서 끝나는 조건확률
1첫 번째 성공0.75
2첫 번째 실패, 두 번째 성공0.25 × 0.75 = 0.1875
3앞의 두 번 실패0.25 × 0.25 = 0.0625

세 번 시도하는 경우에는 마지막 시도의 성공과 실패가 모두 포함된다. 따라서 기대 시도 횟수는 1 × 0.75 + 2 × 0.1875 + 3 × 0.0625 = 1.3125다. 요청 하나가 1.3125번 실행되는 것은 아니다. 많은 요청의 평균을 예상하는 수다.

같은 결과를 더 짧게 계산할 수도 있다. 첫 번째 시도는 항상 하고, 두 번째는 첫 번째가 실패했을 때 하며, 세 번째는 앞의 두 번이 실패했을 때 한다. 따라서 기대 횟수는 1 + q + q²다. 완성 코드에서는 두 계산이 같은지도 확인한다.

실무에서 만나는 곳

ID 충돌 계산은 임시 코드나 무작위 식별자의 공간 크기를 검토할 때 쓰인다. 재시도의 기댓값은 요청 수가 내부 호출 수로 얼마나 늘어날지 예상할 때 쓰인다. 한 번의 호출 비용이 모두 같다면 기대 시도 횟수에 호출 비용을 곱해 요청 하나의 기대 비용을 구할 수 있다.

다만 장애가 지속되는 동안의 재시도는 독립이 아닐 수 있다. 서버가 계속 응답하지 않는 상황에서는 앞선 실패가 다음 실패를 예상하는 정보가 된다. 그때 1 - q³을 적용하면 성공률을 실제보다 높게 예상할 수 있다.

A/B 비교에서도 조건부 비율을 확인한다. 화면별 완료 비율뿐 아니라 기기별·유입 경로별 비율을 나누어 보면 구성 차이를 찾는 데 도움이 된다. 그러나 조건을 나누었다는 사실만으로 화면 변경의 효과가 증명되지는 않는다. 이 장의 도구는 비교의 분모와 가정을 점검하는 데까지 사용한다.

완성 코드

다음 내용을 main.py로 저장한다. 표준 라이브러리의 random만 사용한다. 모의실험은 가정한 무작위 과정을 코드로 여러 번 실행하는 방법이다. seed는 난수 생성기의 시작 상태를 정하는 값이며, 같은 실행 환경에서 결과를 다시 확인할 수 있게 한다.

출력에서는 긴 소수 대신 계산값과 모의실험값의 차이가 정한 허용 범위 안에 있는지를 보여 준다. 확률 차이 0.020은 2퍼센트포인트이며, 시도 횟수 차이 0.020은 평균 횟수의 차이다. 두 값의 단위는 다르다.

import random


def collision_probability(size, count):
    if size <= 0 or count < 0:
        raise ValueError("size는 양수, count는 0 이상이어야 한다")
    if count > size:
        return 1.0

    no_collision = 1.0
    for used in range(count):
        no_collision *= (size - used) / size
    return 1.0 - no_collision


def retry_values(success_rate, limit):
    if not 0.0 <= success_rate <= 1.0 or limit < 1:
        raise ValueError("성공 확률과 시도 제한을 확인해야 한다")

    failure_rate = 1.0 - success_rate
    final_success = 1.0 - failure_rate ** limit
    expected_attempts = sum(
        failure_rate ** index for index in range(limit)
    )
    return final_success, expected_attempts


def simulate_collision(rng, size, count, trials):
    collisions = 0
    for _ in range(trials):
        seen = set()
        for _ in range(count):
            value = rng.randrange(size)
            if value in seen:
                collisions += 1
                break
            seen.add(value)
    return collisions / trials


def simulate_retry(rng, success_rate, limit, trials):
    successes = 0
    total_attempts = 0
    for _ in range(trials):
        for _ in range(limit):
            total_attempts += 1
            if rng.random() < success_rate:
                successes += 1
                break
    return successes / trials, total_attempts / trials


def compare(label, calculated, simulated, tolerance):
    close = abs(calculated - simulated) <= tolerance
    print(
        f"{label}: 계산={calculated:.6f}, "
        f"모의실험 차이<={tolerance:.3f}: {close}"
    )
    assert close


def main():
    space = {
        ("mobile", "done"),
        ("mobile", "left"),
        ("desktop", "done"),
        ("desktop", "left"),
    }
    mobile = {row for row in space if row[0] == "mobile"}
    done = {row for row in space if row[1] == "done"}
    p_done = len(done) / len(space)
    p_both = len(mobile & done) / len(space)
    p_conditional = len(mobile & done) / len(mobile)
    independent = p_both == len(mobile) / len(space) * p_done

    counterexample = [("mobile", "done"), ("desktop", "left")]
    selected = [row for row in counterexample if row[0] == "mobile"]
    overall = sum(row[1] == "done" for row in counterexample)
    overall /= len(counterexample)
    conditional = sum(row[1] == "done" for row in selected)
    conditional /= len(selected)

    print(f"완료={p_done:.2f}, 모바일이며 완료={p_both:.2f}")
    print(f"모바일 중 완료={p_conditional:.2f}, 독립={independent}")
    print(f"반례: 전체 완료={overall:.2f}, 모바일 중 완료={conditional:.2f}")

    assert collision_probability(365, 0) == 0.0
    assert collision_probability(365, 1) == 0.0
    assert collision_probability(2, 3) == 1.0

    trials = 20000
    collision_rng = random.Random(42)
    retry_rng = random.Random(73)
    calculated_collision = collision_probability(365, 23)
    simulated_collision = simulate_collision(
        collision_rng, 365, 23, trials
    )
    calculated_success, calculated_attempts = retry_values(0.75, 3)
    simulated_success, simulated_attempts = simulate_retry(
        retry_rng, 0.75, 3, trials
    )

    weighted_attempts = 1 * 0.75 + 2 * 0.1875 + 3 * 0.0625
    assert calculated_attempts == weighted_attempts

    print(f"모의실험 반복={trials}, seed=42 / 73")
    compare("ID 충돌", calculated_collision, simulated_collision, 0.020)
    compare("최종 성공", calculated_success, simulated_success, 0.020)
    compare("평균 시도", calculated_attempts, simulated_attempts, 0.020)


if __name__ == "__main__":
    main()

줄별 해설

import random은 Python에 포함된 난수 도구를 불러온다. 프로그램은 파일이나 네트워크를 사용하지 않는다. 마지막 조건문은 이 파일을 직접 실행했을 때 main 함수를 호출한다.

collision_probability의 첫 조건문은 사용할 수 없는 입력을 거절한다. raise ValueError는 잘못된 값이 들어왔다는 오류를 발생시킨다. 이 함수의 size와 count는 정수로 전달한다고 정한다. count가 size보다 크면 계산을 계속하지 않고 1.0을 돌려준다.

no_collision은 지금까지 충돌이 없을 확률이다. used가 0부터 하나씩 증가할 때마다 아직 사용하지 않은 값의 비율을 곱한다. *=는 기존 값에 오른쪽 값을 곱해 다시 저장한다. 마지막 줄은 여사건의 확률을 구한다. 매우 작은 충돌 확률에서는 1에 가까운 두 수를 빼며 정밀도가 줄 수 있지만, 이번 365개와 23개의 예제에는 충분한 계산이다.

retry_values는 실패 확률을 먼저 만든다. **는 거듭제곱 연산자다. failure_rate ** limit는 허용된 시도가 모두 실패할 확률이다. sum 안의 식은 첫 시도부터 마지막 시도까지 각 시도가 필요한 확률을 더한다. 이처럼 필요한 값을 차례로 만들어 전달하는 표기를 생성기 표현식이라고 한다.

simulate_collision의 seen은 한 실험 안에서 이미 나온 ID를 보관한다. randrange(size)는 0 이상 size 미만의 정수 하나를 고른다. 이전에 나온 값이면 그 실험의 충돌 수를 한 번 늘리고 안쪽 반복문을 끝낸다. 우리는 중복 발생 횟수가 아니라 충돌이 한 번이라도 있었던 실험의 비율을 구하기 때문이다.

simulate_retry는 시도할 때마다 total_attempts를 먼저 늘린다. random()은 0 이상 1 미만의 수를 만들며, 그 값이 성공 확률보다 작으면 성공으로 처리한다. 성공한 뒤에는 break로 안쪽 반복문을 끝낸다. 끝까지 실패한 요청도 시도 횟수에는 포함된다.

compare에서 abs는 차이의 부호를 없애 크기만 구한다. 문자열의 :.6f는 소수점 아래 여섯 자리까지 표시한다. assert는 뒤의 조건이 참인지 확인하고, 거짓이면 실행을 중단한다. 여기서는 계산과 모의실험의 차이가 허용 범위 안에 있는지 검사한다. 일반 입력 검증을 대신하는 장치로 사용하지는 않는다.

main의 집합들은 확률의 분모를 직접 확인하는 부분이다. 이어지는 counterexample은 독립이 깨지는 기록 두 개를 만든다. sum에 전달한 비교식은 True일 때 1, False일 때 0으로 더해져 완료 기록 수가 된다.

두 난수 생성기를 별도로 만드는 이유는 충돌 실험의 반복 횟수를 바꾸어도 재시도 실험의 난수 흐름이 바뀌지 않게 하기 위해서다. 시작값을 고정해도 현실의 요청이 결정적으로 바뀌는 것은 아니다. 프로그램이 사용하는 모의실험의 순서만 재현한다.

weighted_attempts는 표에서 구한 가중합이다. 앞에서 계산한 1 + q + q²와 같은지 확인한다. 입력 검증을 제외한 핵심 함수들은 계산, 실험, 비교라는 역할로 나뉘어 있어 가정을 바꾸어 살펴보기 쉽다.

실행 결과

python3 main.py

예상 출력은 다음과 같다.

완료=0.50, 모바일이며 완료=0.25
모바일 중 완료=0.50, 독립=True
반례: 전체 완료=0.50, 모바일 중 완료=1.00
모의실험 반복=20000, seed=42 / 73
ID 충돌: 계산=0.507297, 모의실험 차이<=0.020: True
최종 성공: 계산=0.984375, 모의실험 차이<=0.020: True
평균 시도: 계산=1.312500, 모의실험 차이<=0.020: True

True는 이번 모의실험값이 계산값에서 정한 거리 안에 있다는 뜻이다. 정확히 같은 값이라는 뜻은 아니다. 다른 시작값을 사용하거나 반복 횟수를 줄이면 차이도 바뀐다. 시작값 고정은 재현을 돕지만 계산의 옳음을 보장하지 않는다.

모의실험과 계산은 같은 가정을 공유한다. 둘이 비슷하더라도 현실의 ID 생성기가 균등한지, 실제 장애가 독립적인지는 별도로 확인해야 한다. 또한 허용 차이 0.020은 이 예제의 비교 기준이며, 통계적으로 보장된 오차 범위로 제시한 값은 아니다.

실무에서 자주 틀리는 것

조건을 걸고도 전체를 분모로 사용한다

다음 코드는 모바일 중 완료 비율을 구하려고 하지만 전체 기록 수로 나눈다. 동시 발생 비율을 조건부 비율로 잘못 부르는 경우다.

rows = [
    ("mobile", "done"),
    ("mobile", "left"),
    ("desktop", "done"),
]
mobile_done = sum(
    device == "mobile" and status == "done"
    for device, status in rows
)
wrong = mobile_done / len(rows)
print(round(wrong, 3))

출력은 0.333이다. 조건을 만족하는 기록 수를 분모로 사용해야 한다.

mobile_count = sum(device == "mobile" for device, status in rows)
if mobile_count == 0:
    print("모바일 기록 없음")
else:
    corrected = mobile_done / mobile_count
    print(round(corrected, 3))

고친 코드의 출력은 0.5다. 기록이 없는 경우에 0을 반환하면 “관찰할 대상 없음”과 “대상이 있지만 완료가 없음”을 혼동하게 된다.

실패 확률이 같으면 독립이라고 생각한다

하나의 장애 상태가 세 번의 시도에 함께 영향을 준다고 하자. 다음 계산은 각 시도의 실패 확률이 0.25라는 정보만으로 실패 확률을 세 번 곱한다.

failure_rate = 0.25
wrong_success = 1 - failure_rate ** 3
print(wrong_success)

출력은 0.984375다. 하지만 요청을 시작할 때 정해진 장애 상태가 세 번 내내 유지되고, 장애가 없으면 첫 시도에서 성공한다면 결과가 다르다.

outage_states = [False, False, False, True]
successes = 0
for outage in outage_states:
    for _ in range(3):
        if not outage:
            successes += 1
            break
print(successes / len(outage_states))

네 상태를 같은 확률로 고르는 이 모형의 성공 확률은 0.75다. 재시도가 성공률을 높이는지 판단하려면 실패가 다음 시도까지 이어지는 구조를 살펴야 한다.

기댓값에 도달한 결과가 반드시 있다고 생각한다

평균 시도 횟수의 소수 부분을 버리면 여러 요청의 비용을 작게 잡는다.

expected_attempts = 1.3125
wrong_total = int(expected_attempts) * 1000
print(wrong_total)

출력은 1000이다. 예상 비용은 소수 부분을 유지한 채 계산한다.

expected_total = expected_attempts * 1000
print(expected_total)

출력은 1312.5다. 실제 총시도 횟수는 정수지만 그 기댓값은 소수일 수 있다. 이 값을 처리 용량의 상한으로 해석해서도 안 된다.

모의실험값과 계산값을 정확히 비교한다

유한 번의 실험에서 얻은 비율은 계산값과 다를 수 있다. 다음 코드는 모의실험값을 가정한 작은 예다.

calculated = 0.5072972343239857
simulated = 0.509
print(calculated == simulated)

출력은 False다. 차이를 확인하려는 목적이라면 비교 기준을 명시한다.

tolerance = 0.020
print(abs(calculated - simulated) <= tolerance)

출력은 True다. 이 비교는 정한 기준을 만족하는지만 알려 준다. 모의실험이 충분한지 판단할 때는 반복 횟수와 드문 사건의 발생 횟수도 함께 봐야 한다. 충돌을 관찰하지 못했다는 이유만으로 충돌 확률이 0이라고 결론 내릴 수 없다.

한눈에 보기

확률 계산을 코드로 옮길 때의 질문
개념의미코드에서 확인할 것
표본 공간가능한 결과 전체무엇을 한 번 고르는지 정한다
사건관심 있는 결과의 모음조건으로 대상을 고른다
조건부 확률조건을 만족하는 대상 안의 비율분모도 조건에 맞게 바꾼다
독립한 사건의 정보가 다른 사건의 확률을 바꾸지 않음동시 확률과 개별 확률의 곱을 비교한다
여사건관심 사건이 일어나지 않는 사건충돌 없음부터 계산한다
기댓값값에 확률을 곱해 더한 평균의 기준성공과 실패의 비용을 모두 포함한다
모의실험가정한 과정을 반복하여 비율과 평균을 관찰함시작값, 반복 횟수, 비교 기준을 기록한다

연습 문제

  1. 같은 확률로 고르는 기록이 모바일 완료 2개, 모바일 이탈 1개, 데스크톱 완료 1개라고 하자. 전체 완료 확률과 모바일 중 완료 확률을 구하라. 두 사건이 독립인지도 확인하라. 중복 기록이 사라지지 않도록 리스트를 사용하라.
  2. 가능한 ID가 10개일 때 2개를 만들면 충돌 확률은 얼마인가. 완성 코드의 함수로 확인하라. 이어서 11개를 만들 때의 값과 그 이유를 설명하라.
  3. 한 번의 성공 확률이 0.5이고 최대 네 번 시도한다고 하자. 최종 성공 확률과 기대 시도 횟수를 계산하라. 기대 횟수가 네 번보다 작은 이유를 설명하라.
  4. 화면 A의 완료 기록이 8개, 전체 기록이 10개이고 화면 B의 완료 기록이 9개, 전체 기록이 10개다. 관찰된 완료 비율을 출력하라. 이 결과만으로 다음 사용자에게도 B가 더 좋다고 결론 내릴 수 있는지 설명하라.

정답과 해설

기록의 수와 상태의 종류를 구별한다

rows = [
    ("mobile", "done"),
    ("mobile", "done"),
    ("mobile", "left"),
    ("desktop", "done"),
]
mobile_rows = [row for row in rows if row[0] == "mobile"]
p_done = sum(row[1] == "done" for row in rows) / len(rows)
p_mobile_done = (
    sum(row[1] == "done" for row in mobile_rows) / len(mobile_rows)
)
print(round(p_done, 3), round(p_mobile_done, 3))
print(abs(p_done - p_mobile_done) < 0.000001)

출력은 첫 줄에 0.75 0.667, 다음 줄에 False다. 모바일이라는 조건을 알면 완료 확률이 달라지므로 독립이 아니다. 이 기록들을 집합으로 바꾸면 모바일 완료 두 개가 하나로 합쳐져 다른 실험이 된다.

두 ID는 한 쌍만 비교한다

print(round(collision_probability(10, 2), 3))
print(collision_probability(10, 11))

완성 코드의 함수를 사용하면 출력은 0.1과 1.0이다. 첫 값은 무엇이든 가능하고 두 번째 값이 첫 값과 같을 확률은 1/10이다. 열한 개를 열 개의 가능한 값에 배정하면 적어도 하나는 반복된다.

나중 시도는 일부 요청에만 필요하다

success, attempts = retry_values(0.5, 4)
print(success, attempts)

출력은 0.9375 1.875다. 모두 실패할 확률은 0.5의 네제곱인 0.0625다. 기대 시도 횟수는 1 + 0.5 + 0.25 + 0.125다. 앞에서 성공한 요청은 뒤의 시도를 하지 않으므로 매번 네 번을 사용하는 것보다 작다.

관찰된 비율과 미래의 확률을 구별한다

print(8 / 10, 9 / 10)

출력은 0.8 0.9다. 이 기록에서는 B의 완료 비율이 높다. 그러나 기록 수가 적고, 사용자 배정 방식이나 접속 조건도 주어지지 않았다. 우연한 차이와 조건 구성의 차이를 배제할 수 없으므로 이 숫자만으로 미래의 효과를 결론 내릴 수 없다.

확률을 코드로 다룰 때는 계산식 앞에 실험의 단위와 가정을 적어 둔다. 다음 장에서 정수와 나머지로 값의 범위와 반복 구조를 다룰 때도, 가능한 값이 몇 개인지와 실제로 어떻게 선택되는지를 구별하는 습관이 이어진다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.