Gompertz Curve - 바이너리 피드백 온도 함수 설계

바이너리 피드백을 온도로 압축하는 함수 설계. 포화 일차함수와 로지스틱 및 의 한계, 그리고 출발점이 곧 변곡점이 되는 곰페르츠의 정합성까지

sillysillyman··26분 읽기

들어가며

당근마켓의 매너온도는 어떤 함수로 구현되어 있을까? 진행 중인 사이드 프로젝트에 사용자 간 피드백을 점수화하여 온도로 나타내는 기획이 주어졌고, 매너온도를 벤치마킹 대상으로 삼게 됐다. 정확한 계산식은 공개되어 있지 않으니, 좋아요/싫어요 같은 binary 피드백을 0~100 사이의 신뢰 지표 하나로 압축하는 함수를 직접 설계해보기로 했다.
요구사항은 다섯 항목으로 정리된다.
  • 출발은 36.5도: 기본 온도이자 신규 사용자의 중립 baseline
  • 0 ~ 100 범위로 bounded: 무한히 발산하지 않도록
  • 양/음 리뷰의 차이를 반영: likeCount - dislikeCount를 입력으로
  • 변화는 점진적: 리뷰 한두 개로 급격히 100에 도달하면 지표 신뢰성이 훼손됨
  • 긍정 피드백을 더 강하게, 부정 피드백을 덜 강하게: 긍정 리뷰는 충분한 보상감을, 부정 리뷰는 완화된 영향력을 부여해 사용자 경험을 보전
표면적으로는 한 줄 함수로 끝날 듯 보이지만, 식을 구체화해보면 결코 그렇지 않다. 단순한 형태에서 출발해 점진적으로 정교화하며 왜 곰페르츠에 안착하게 되는지 따라가 본다.

출발선: 포화 일차함수

가장 단순한 baseline에서 출발한다. 실제 후보로 진지하게 고려한 형태는 아니며, 왜 더 정교한 곡선이 요구되는가를 드러내기 위한 논의의 출발점이다. piecewise linear 함수다. 중앙 구간은 기울기 α\alpha의 직선이며, 양 끝은 y=0y = 0y=100y = 100의 두 수평선에서 포화된다.
y(x)={0x36.5/α36.5+αx36.5/α<x<63.5/α100x63.5/αy(x) = \begin{cases} 0 & x \le -36.5/\alpha \\ 36.5 + \alpha x & -36.5/\alpha < x < 63.5/\alpha \\ 100 & x \ge 63.5/\alpha \end{cases}
한 줄로 적으면 min(100, max(0, 36.5+αx))\min(100,\ \max(0,\ 36.5 + \alpha x)). 구현은 자명하다.
kotlin
val y = (36.5 + alpha * x).coerceIn(0.0, 100.0)
그러나 즉시 결함이 드러난다.
  1. 임계 근처에서 정보가 소실된다. α=0.5\alpha = 0.5라면 좋아요 - 싫어요 차이가 127을 초과하는 순간부터 모든 사용자가 100에 수렴한다. 좋아요 200개를 받은 사용자와 1000개를 받은 사용자가 동일한 점수로 처리된다.
  2. 미분이 불연속이다. y=0y = 0, y=100y = 100과 만나는 지점에서 곡선에 kink(꺾임)가 발생한다. 리뷰 한 개 추가 시의 변화량이 임계 근처에서 급격히 0으로 떨어진다.
  3. 한 표의 영향력이 위치 의존성을 갖지 않는다. 리뷰 1건당 균일하게 α\alpha도 증가한다. 그러나 직관적으로 0건 → 1건의 좋아요는 큰 의미를 가져야 하고, 50건 → 51건은 거의 무의미해야 한다. 선형 함수는 이러한 diminishing returns를 표현하지 못한다.
  4. 좋아요와 싫어요가 정확히 동일한 가중치를 갖는다. 중앙 구간에서 좋아요 한 표는 +α+\alpha도, 싫어요 한 표는 α-\alpha도. 다섯 번째 요구사항인 긍정 가중 / 부정 감쇄를 구현하려면 별도의 계수를 도입해 36.5+αLlikeCountαDdislikeCount36.5 + \alpha_L \cdot \text{likeCount} - \alpha_D \cdot \text{dislikeCount} 형태로 강제해야 한다. 함수 형태 자체가 요구사항을 구조적으로 지지하지 못한다.
세 번째와 네 번째 결함이 본질적이다. 양 끝에서 매끄럽게 saturate되면서 좌우 비대칭을 갖는 곡선이 요구된다.

첫 후보: 로지스틱과 tanh\tanh

시그모이드는 S자 곡선 전반을 가리키는 family 명칭이고, 그중 가장 표준적인 형태가 로지스틱이다. 실제로 가장 먼저 검토한 곡선이 이쪽이다. 표준 로지스틱은 다음과 같다.
σ(x)=11+ex\sigma(x) = \frac{1}{1 + e^{-x}}
치역 (0,1)(0, 1), 변곡점 (0,0.5)(0, 0.5)이다. 우리 요구사항을 충족하려면 세 매개변수를 도입해 일반화한다.
y(x)=L1+ek(xx0)y(x) = \frac{L}{1 + e^{-k(x - x_0)}}
  • LL: 상한. 요구사항 0~100 bound로부터 L=100L = 100
  • kk: 기울기 (sensitivity)
  • x0x_0: 수평이동. 요구사항 y(0)=36.5y(0) = 36.5로부터 x00.554/kx_0 \approx 0.554/k로 역산
tanh\tanh로 표현해도 본질적으로 동일한 곡선이다. tanh\tanh와 로지스틱은 affine 변환 관계에 있다.
장점은 분명하다.
  • 0~100 범위로 자연스럽게 bounded
  • 양 끝에서 매끄럽게 saturate, kink 없음
  • CC^\infty 매끄러움 (무한 번 미분 가능)
  • 임계 근처에서 한 표의 영향이 점진적으로 감소: diminishing returns 자동 충족
출발선의 1~3번 결함을 일거에 해소한다. 그러나 결정적인 한계 하나가 남는다.
대칭성이다.
세 매개변수 보정(L=100L = 100, x00.554/kx_0 \approx 0.554/k)을 모두 적용해도 두 문제가 남는다.
첫째, x0x_0가 의미를 상실한다. y(0)=36.5y(0) = 36.5를 충족하기 위해 x00.554/kx_0 \approx 0.554/k로 끼워 맞춘 값일 뿐, 매개변수에 의미론적 해석을 부여할 수 없다. 사람의 체온이라는 직관과 연결되는 자연 상수가 아니라 단순한 역산 결과다.
둘째, 더 본질적인 한계다. 로지스틱의 최대 기울기 지점은 구조적으로 항상 y=L/2=50y = L/2 = 50에 고정된다. 어떤 x0x_0로도 옮길 수 없다.
이를 보이기 위해 미분을 정리해본다. 수평이동 x0x_0는 단순한 평행이동이라 최대 기울기의 yy값에는 영향을 주지 않으므로, 편의상 x0=0x_0 = 0으로 두고 분석한다.
y(x)=L1+ekxy(x) = \frac{L}{1 + e^{-kx}}
먼저 연쇄법칙으로 그대로 미분하면:
dydx=L(1+ekx)2(kekx)=Lkekx(1+ekx)2\frac{dy}{dx} = -L(1+e^{-kx})^{-2}(-k e^{-kx}) = \frac{Lk e^{-kx}}{(1+e^{-kx})^2}
이 형태는 xx의 함수라 어떤 yy값에서 기울기가 최대인지가 직접 드러나지 않는다. dy/dxdy/dxyy에 대한 식으로 바꿔야 하며, 이를 위해 ekxe^{-kx}yy로 치환한다. 정의식에서:
1+ekx=Ly    ekx=Lyy1 + e^{-kx} = \frac{L}{y} \implies e^{-kx} = \frac{L - y}{y}
이를 위 식에 대입하면:
dydx=Lk(Ly)/y(L/y)2=ky(Ly)L=ky(1yL)\frac{dy}{dx} = \frac{Lk(L-y)/y}{(L/y)^2} = \frac{ky(L - y)}{L} = ky\left(1 - \frac{y}{L}\right)
이 값은 y=L/2y = L/2에서 최대이며, L=100L = 100인 우리 케이스에서는 y=50y = 50이 곧 최대 기울기 지점이다. x0x_0를 어디로 옮기든 어떤 xx값에서 최대 기울기가 나타나는지만 달라질 뿐, 어떤 yy값에서 변동폭이 최대인지는 항상 L/2L/2에 고정된다.
문제는 다음과 같다. 36.5에서 출발한 신규 사용자는 첫 몇 개의 리뷰에서 변화가 미미하다 (변곡점에서 멀어 기울기가 작음). 점수가 50에 근접할수록 리뷰 한 표의 영향이 점차 커지고, 50을 초과하면 다시 감소한다. 즉 좋아요가 누적되어 50 근처에 머무는 사용자가 가장 큰 변동을 경험한다. 다섯 번째 요구사항의 관점에서도 부자연스럽다. 변동폭이 큰 구간이 36.5 위쪽(좋아요 누적 영역)에 치우쳐 있어 긍정 가중과 부합하는 듯 보이지만, 동일 논리로 50 이상의 영역에서는 변동이 다시 감소하므로 일관된 가중치 체계는 형성되지 않는다.
곡선의 최대 민감 지점이 출발점이 아닌 임의의 중간 위치에 놓인다는 사실 자체가 부자연스럽다. 신규 사용자가 최고 반응성 위치에 있어야 마땅하지, 일정 점수를 누적한 사용자가 가장 큰 변동을 겪는 구조는 타당하지 않다.

같은 곡선, 두 가지 한계

여기까지가 sigmoid 형태로 매개변수화했을 때의 한계다. 동일 곡선을 tanh\tanh 형태로 재매개변수화하면 동등한 한계에 부딪히지만, 노출되는 양상이 다르다. tanh\tanh는 변곡점을 중심으로 점 대칭(원점 대칭)의 거동을 갖는 곡선이라, 변곡점(= 최대 변동폭 지점)을 임의 위치에 자유롭게 배치할 수 있다.
y(x)=36.5+Atanh(kx)y(x) = 36.5 + A \tanh(kx)
여기서 AA는 진폭(amplitude)을 의미한다. 이렇게 매개변수화하면 변곡점 yy가 정확히 36.5에 위치하여 36.5에서 최대 변동이라는 요구가 자동 충족된다. 그러나 [0,100][0, 100] bound가 와해된다. 변곡점 36.5를 기준으로 상향 63.5(→100), 하향 36.5(→0)의 여유가 비대칭인데, tanh\tanh는 대칭이라 이 격차를 흡수하지 못한다.
  • A=63.5A = 63.5 → 최고 100, 최저 -27 (음수 온도 발생)
  • A=36.5A = 36.5 → 최저 0, 최고 73 (100에 도달 불가)
한쪽 bound를 충족하면 반대쪽이 와해된다. 결국 외부에서 포화 처리로 보정해야 하는데, 이는 곡선 형태로 풀지 못한 문제를 코드로 우회하는 임시방편이다.
동일 곡선이 두 framing에서 서로 다른 지점에 한계를 노출한다.
framing고정한계 지점
로지스틱bound [0,100][0, 100]변곡점 yy가 50에 고정 → 출발점과 분리
tanh\tanh센터 36.5 (변곡점)상하 여유의 비대칭으로 bound 충족 불가
대칭이라는 단어 한 줄로는 환원되지 않는 문제다. 이 곡선들 자체가 요구사항과 비호환이다. 매개변수화 방식이나 고정 제약의 선택과 무관하게, 대칭성이 본질적 장애로 작동한다. 근본적으로 비대칭 곡선이 요구된다.
그럼 tanhtanh에 수직이동까지 허용하면? y=c+Atanh(k(xx0))y = c + A\tanh(k(x-x_0))에서 bound [0,100][0,100]을 충족하려면 c=50c = 50, A=50A = 50이 강제된다. 이 형태는 tanh(z)=2σ(2z)1\tanh(z) = 2\sigma(2z) - 1 항등식으로 풀면 y=100/(1+e2k(xx0))y = 100/(1+e^{-2k(x-x_0)}), 즉 로지스틱과 동일하다. 결국 어떤 매개변수화를 동원해도 대칭 시그모이드 family 안에서는 변곡점이 L/2=50L/2 = 50에 고정된다는 본질은 우회되지 않는다.

최종 채택: 곰페르츠(Gompertz) 곡선

곰페르츠 곡선은 비대칭 S자 곡선의 대표 사례로, 어떤 양이 상한에 점근적으로 다가가는 모든 성장·포화 현상을 모델링하는 데 두루 활용된다. 인간 사망률, 종양 성장, 기술 확산이 대표적이다.
y(x)=aebecxy(x) = a e^{-b e^{-cx}}
  • aa: 상한 (asymptote)
  • bb: x=0x=0에서의 값을 결정하는 shift
  • cc: 곡선의 기울기 (sensitivity)
형태는 로지스틱과 동일한 S자이나 비대칭이다. 변곡점의 yy값이 a/2a/2가 아닌 a/e0.368aa/e \approx 0.368a에 위치한다.

출발점과 변곡점의 일치

상한 a=100a = 100, x=0x = 0에서 y=36.5y = 36.5를 풀면:
36.5=100eb    b=ln(0.365)1.00836.5 = 100 e^{-b} \implies b = -\ln(0.365) \approx 1.008
흥미로운 일치가 드러난다. a/e=100/e36.79a / e = 100 / e \approx 36.79. 36.5는 사실상 곰페르츠 곡선의 자연 변곡점과 거의 정확히 일치한다. 인간 체온이 우연히도 곰페르츠의 가장 의미 있는 지점과 부합한다.
잠깐, 로지스틱에서 지적한 수평이동과 무엇이 다른가? bb x=0x=0 yy값을 끼워 맞춘 것과 본질적으로 동일하지 않은가?
수학적으로는 동일한 수평이동에 해당한다. 곰페르츠에서 bb의 변경은 xx축 shift와 1:1 대응 관계에 있다. bbecx0b \to b e^{c x_0}이 정확히 x0x_0만큼의 수평이동에 대응한다. bb는 수평이동 매개변수의 한 형태일 뿐이다.
차이는 활용 가능한 평행이동의 범위에 있다. 수직이동은 곡선의 치역을 일괄 이동시켜 y[0,100]y \in [0, 100] bound를 위배하므로 사용 불가능하다. 허용되는 것은 수평이동뿐이며, 수평이동은 어떤 xx값에 어떤 yy가 나오는가는 바꿔도 어떤 yy값에서 최대 기울기가 나오는가는 옮길 수 없다.yy값은 곡선의 형태에 내재된 상수다.
  • 로지스틱: 변곡점 yy가 항상 L/2=50L/2 = 50. 수평이동 불변량.
  • 곰페르츠: 변곡점 yy가 항상 a/e36.79a/e \approx 36.79. 수평이동 불변량.
요구되는 출발점 yy는 36.5다. 50과는 거리가 멀고 36.79와는 거의 일치한다. 결과적으로 동일한 수평이동으로 y(0)y(0) 맞추기 전략이 두 곡선에서 정반대의 결과를 도출한다.
  • 로지스틱 수평이동: y(0)=36.5y(0) = 36.5는 충족하지만 변곡점 yy5050으로 고정 (출발점과 최대 응답성 지점이 분리)
  • 곰페르츠 수평이동: y(0)=36.5y(0) = 36.5를 충족하는 순간 변곡점 yy도 거의 동일 지점 (출발점과 최대 응답성 지점이 거의 일치)
변곡점은 정확히 최대 기울기 지점이다. 따라서 곰페르츠에서는 신규 사용자가 곧 최대 민감도를 갖는 위치에 놓인다. 첫 좋아요, 첫 싫어요가 가장 큰 변화를 야기하고, 점수가 상하로 이동할수록 변화가 감쇠한다. bb 자체는 로지스틱의 x0x_0와 마찬가지로 수평이동 파라미터지만, 곰페르츠의 구조 덕에 그 수평이동이 출발점 \approx 변곡점이라는 정합성을 부산물로 제공한다. 로지스틱에서는 얻을 수 없던 결과다.

자연스럽게 따라오는 가중치 비대칭

곰페르츠를 곰페르츠로 특정짓는 본질은 변곡점의 위치뿐 아니라 양쪽 tail의 길이가 다르다는 점에 있다.
  • 위쪽 asymptote 100으로는 단일 지수로 점진적으로 수렴: 큰 xx에 대해 100y100becx100 - y \approx 100 b e^{-cx}
  • 아래쪽 asymptote 0으로는 이중 지수로 급격히 수렴: 음수 xx에 대해 y100ebecxy \approx 100 e^{-b e^{c|x|}}
이 비대칭성이 좋아요/싫어요의 가중치를 곡선 자체로 차별화한다. sensitivity = 0.1에서 양쪽 대칭점들의 yy값을 비교하면 명확히 드러난다.
likeCount - dislikeCounttemperatureΔ\Delta(36.5 대비)
+2087.25+50.75
+1069.02+32.52
036.500
-106.46-30.04
-200.06-36.44
동일한 ±20에서 상향은 +50.75, 하향은 -36.44. 두 비대칭이 동시에 작동한 결과다.
  1. 범위 비대칭. 상향 63.5, 하향 36.5의 여유. 출발점을 36.5로 둔 데서 비롯된 부산물이다.
  2. 수렴 속도 비대칭. 싫어요 20개면 yy가 이미 0.06으로, 하향 saturation에 사실상 도달한 상태다. 이후의 싫어요는 점수에 거의 영향을 미치지 못한다. 반면 좋아요 20개로는 상향 영역의 80%에만 도달했으며, 100까지는 상당한 여유가 남아 있다.
두 효과가 동일 방향으로 결합한다.
  • 좋아요는 누적되어도 지속적으로 유의미한 기여를 한다. 99도에서 100도까지의 경로가 길게 남아 있다.
  • 싫어요는 일정 임계 이후 사실상 영향력을 상실한다. y=0y=0 부근에 위치한 사용자에게 싫어요 한 표가 추가되어도 점수는 거의 변동하지 않는다.
다섯 번째 요구사항이었던 긍정 가중 / 부정 감쇄가 가중치 계수 없이 곡선의 형태 자체로 충족된다. 코드에 αL\alpha_L, αD\alpha_D 같은 가중치를 별도로 도입하지 않고 sensitivity 단일 파라미터만으로 운용 가능한 이유다.

코드 구현

kotlin
@Component
class TemperatureCalculator(
    @Value("\${app.temperature.sensitivity}") private val sensitivity: Double,
) {
    fun calculate(
        likeCount: Long,
        dislikeCount: Long,
    ): Double {
        val x = (likeCount - dislikeCount).toDouble()
        return MAX * exp(-BASE_OFFSET * exp(-sensitivity * x))
    }

    companion object {
        private const val MAX = 100.0
        private const val BASE_TEMPERATURE = 36.5
        private val BASE_OFFSET = -ln(BASE_TEMPERATURE / MAX)
    }
}
BASE_OFFSET이 앞서 도출한 b1.008b \approx 1.008. 운영 시점에 조정 가능한 파라미터는 sensitivity 하나로 수렴한다.

돌아보며

동일한 요구사항을 세 함수 후보에 순차적으로 대조하면 다음과 같이 정리된다.
단계형태한계 지점
출발선포화 일차함수임계 정보 손실, kink, diminishing returns 부재, 좌우 대칭
첫 후보로지스틱 / tanh\tanh대칭 구조: bound 고정 시 변곡점이 y=50y = 50, 센터 고정 시 bound가 비대칭으로 와해
채택곰페르츠변곡점 yy값이 a/e36.79a/e \approx 36.79. 출발점이 곧 최대 응답성, 위/아래 tail 비대칭
매번 새로운 요구사항이 추가된 것은 아니다. 처음부터 동일한 항목이었으나, 후보를 하나씩 검증하고 나서야 그 항목들이 비대칭 S-curve 위, 변곡점이 36.5, 위쪽 tail이 더 긴이라는 매우 구체적인 곡선 하나를 지목하고 있었다는 사실을 인지하게 됐다.
특히 로지스틱에서의 통찰이 컸다. 초기에는 S자 골격에 sensitivity만 적절히 조정하면 충분하리라고 막연히 가정했으나, 대칭이라 부적합하다는 한 줄로 환원되는 문제가 아니었다. 수평이동이라는 자명한 보정책이 존재하지만, 그것이 해소하는 것은 xx축의 위치뿐이고 yy축에 내재된 구조적 제약(최대 변동폭 = L/2L/2)은 그대로 남는다. 비대칭 곡선이 필요한 이유는 단순히 36.5에서 출발해야 하기 때문이 아니라, 출발점 = 최대 응답성 지점이라는 비자명한 정합성을 곡선 구조 자체에서 해소해야 했기 때문이다.
요구사항을 문서화하는 시점에는 드러나지 않다가, 실제 함수 형태를 시각화하고 한계에 부딪쳐 봐야 비로소 노출되는 제약이 존재한다. 곰페르츠를 사전에 알고 있었다면 후보 검토 단계 자체를 생략할 수 있었겠지만, 생략했더라면 왜 곰페르츠인가라는 본질적 질문은 답하지 못한 채 남았을 것이다. 그 답은 결국 단순한 형태들의 한계로부터만 도출된다.

남은 한계

  • 시간 가중치 부재: 1년 전 싫어요와 어제의 싫어요가 동일한 1로 계산됨
  • 리뷰어 신뢰도 미반영: 저평판 사용자의 좋아요와 고평판 사용자의 좋아요가 등가로 처리됨
  • 0과 100 도달 불가: 도달 불가능하며, 표시 시점의 소수점 정밀도는 별도 결정 사항
이들은 곡선 형태의 문제가 아닌 입력 정의의 문제다. xx를 어떻게 구성하여 입력할 것인가의 영역이며, 곰페르츠 함수는 그대로 둔 채로 해결 가능하다.

참고