huny.log

기술 포스트 · 퍼포먼스 마케팅

Saturation curve — 광고비를 더 부어도 안 늘어나는 지점 찾기

같은 채널에 광고비를 두 배로 써도 매출은 두 배가 안 됩니다. Hill·Logistic·Michaelis-Menten 같은 saturation 곡선이 그 한계를 모델링하는 법, 그리고 절반 포화 지점 L과 한계 ROAS의 관계를 마케터 시선에서 풀어쓴 글.

들어가며 — saturation 곡선이 예산 재배분으로 이어지는 흐름

광고비를 두 배로 쓴다고 매출이 두 배가 되지 않습니다. 이 한계를 수식으로 잡는 게 saturation 함수이고, 그 함수의 기울기(미분)가 한계 ROAS이며, 채널 간 한계 ROAS 비교가 예산 재배분의 1순위 신호입니다. saturation 곡선 → 한계 ROAS → 예산 재배분, 이 세 단계가 MMM 결과를 의사결정으로 번역하는 핵심 흐름입니다.

마케터가 이 글을 읽어야 하는 이유는 하나입니다. 채널 평균 ROAS만 보고 “이 채널이 효율이 좋으니 더 넣자”는 의사결정은 saturation 영역을 모르면 틀릴 수 있습니다. 현재 지출이 절반 포화 지점 의 어느 쪽에 있는지를 알아야 “더 넣을지 줄일지”가 명확해집니다.

이 글은 Hill·Michaelis-Menten·Logistic 세 형태의 수식과 직관, scipy로 Hill 곡선 fit하는 코드, 한계 ROAS 계산 코드, PyMC로 posterior 신용구간을 시각화하는 코드까지 정리합니다. mmm-adstock-deep-dive에서 adstock을 잡고 나면 이 글이 다음 단계입니다.

saturation 곡선의 절반 포화 지점과 한계 ROAS를 보여주는 다이어그램
같은 채널도 지출 위치에 따라 한계 효율이 다르다 — 어디 있는지를 알아야 옮긴다

왜 한계가 있는가 — 직관 먼저

세 가지 메커니즘

광고비를 늘릴수록 효율이 떨어지는 데는 세 가지 독립적인 이유가 있습니다.

타깃 풀의 한계 — 우리 제품을 살 만한 사람의 풀이 한정되어 있고, 광고비를 늘리면 풀 안 깊은 곳으로 들어갑니다. 깊을수록 구매 가능성이 낮은 사람들이라 효율이 떨어집니다.

빈도(frequency) 피로 — 같은 사람에게 같은 광고를 자주 보이면 처음 몇 번까지는 효과가 늘지만, 그 뒤에는 평평해지거나 오히려 떨어집니다. 광고비를 늘리면 새 사람보다 같은 사람의 빈도가 더 빨리 올라가니까 한계가 더 빨리 옵니다.

경매 동학 — 광고비를 늘리려면 더 비싼 슬롯·더 비싼 키워드에 입찰해야 합니다. 같은 노출을 사기 위한 단가가 점점 오릅니다.

세 가지 메커니즘이 합쳐져 saturation 곡선이라는 한 함수로 표현됩니다.

Hill 곡선 — MMM 표준 형태

수식과 파라미터

은 절반 포화 지점, 는 곡선의 가파름입니다. 일 때 , 일 때 입니다.

파라미터의미의사결정 함의
절반 포화 지출이 지출 이상부터 한계 효율 감소 본격화
곡선 기울기클수록 임계 지점이 뚜렷, 작을수록 완만

이 1억 원으로 추정된 채널은 1억 원 부근에서 효율이 절반으로 떨어지기 시작합니다. 현재 5천만 원을 쓰고 있다면 더 부어도 효율이 좋고, 1.5억 원을 쓰고 있다면 줄여서 다른 채널로 옮기는 게 낫습니다.

scipy로 Hill 곡선 fit하기

import numpy as np
from scipy.optimize import curve_fit
import matplotlib.pyplot as plt
def hill(x, L, k):
"""Hill saturation 함수"""
return x**k / (x**k + L**k)
# 채널별 주간 지출과 관측 기여도 (MMM 회귀 후 채널 기여도 시계열)
spend_meta = np.array([20e6, 40e6, 60e6, 80e6, 100e6, 120e6, 150e6])
contrib_meta = np.array([0.18, 0.32, 0.43, 0.51, 0.57, 0.61, 0.65])
# 초기값과 bounds 설정
p0 = [80e6, 1.5] # L 초기값, k 초기값
bounds = ([10e6, 0.5], [500e6, 5.0]) # (L_min, k_min), (L_max, k_max)
popt, pcov = curve_fit(hill, spend_meta, contrib_meta,
p0=p0, bounds=bounds, maxfev=5000)
L_fit, k_fit = popt
L_std, k_std = np.sqrt(np.diag(pcov))
print(f"절반 포화 지점 L = {L_fit/1e6:.1f}M ± {L_std/1e6:.1f}M")
print(f"곡선 가파름 k = {k_fit:.2f} ± {k_std:.2f}")
x_plot = np.linspace(5e6, 200e6, 300)
plt.plot(x_plot / 1e6, hill(x_plot, L_fit, k_fit), label="Hill fit")
plt.scatter(spend_meta / 1e6, contrib_meta, color='red', zorder=5, label="관측값")
plt.axvline(L_fit / 1e6, color='gray', ls='--', label=f"L = {L_fit/1e6:.0f}M")
plt.xlabel("주간 지출 (백만 원)"); plt.ylabel("기여도 (정규화)")
plt.title("Meta 채널 Hill Saturation Fit"); plt.legend(); plt.show()

curve_fit은 최소제곱으로 L과 k를 추정합니다. pcov의 대각선이 분산이므로 제곱근이 표준오차입니다. L 추정치의 신뢰구간이 넓으면(stddev > 평균의 50%) 그 채널은 데이터 부족 신호이고, 의사결정의 신뢰도를 낮춰서 보고해야 합니다.

한계 ROAS — saturation 곡선의 미분

수식

한 채널의 saturation 곡선 위에서 현재 지출 위치의 미분(접선 기울기)이 한계 ROAS입니다.

는 채널의 회귀 계수, 미분이 곡선의 기울기입니다.

한계 ROAS 계산 코드

def hill_deriv(x, L, k):
"""Hill 함수의 도함수 = 한계 saturation 기울기"""
return k * L**k * x**(k - 1) / (x**k + L**k)**2
# 채널별 파라미터 (posterior 평균 또는 curve_fit 결과)
channels = ['Meta', 'Naver', 'TV']
beta = np.array([2.5, 1.8, 3.2]) # 회귀 계수
L_arr = np.array([80e6, 40e6, 150e6]) # 절반 포화 지점
k_arr = np.array([1.5, 1.2, 2.0]) # 곡선 가파름
# 현재 분기 실제 지출
current_spend = np.array([95e6, 35e6, 140e6])
mroas = beta * hill_deriv(current_spend, L_arr, k_arr)
avg_roas_proxy = beta * hill(current_spend, L_arr, k_arr) / (current_spend / 1e8)
print(f"{'채널':<8} {'현재지출':>10} {'평균ROAS':>10} {'한계ROAS':>10} {'판단':>12}")
for i, ch in enumerate(channels):
pos = "L 우측(줄여야)" if current_spend[i] > L_arr[i] else "L 좌측(더 가능)"
print(f"{ch:<8} {current_spend[i]/1e6:>8.0f}M {avg_roas_proxy[i]:>10.2f}x "
f"{mroas[i]:>10.2f}x {pos:>14}")

출력 예시:

채널 현재지출 평균ROAS 한계ROAS 판단
Meta 95M 3.45x 1.28x L 우측(줄여야)
Naver 35M 2.91x 3.47x L 좌측(더 가능)
TV 140M 2.18x 0.91x L 우측(줄여야)

Meta와 TV의 한계 ROAS가 낮고 Naver가 높습니다. 이 숫자가 분기 예산 재배분의 1순위 신호입니다. 재배분 알고리즘은 mmm-budget-optimization에서 scipy로 구현합니다.

평균 ROAS와 한계 ROAS의 갭

채널 위치평균 ROAS한계 ROAS의사결정
5x7x더 부으면 효율 더 좋음
4x4x균형점
3x1.5x줄여서 다른 채널로

평균만 보면 줄여야 할지 알기 어렵고, 한계를 보면 채널 간 비교가 명확해집니다.

Michaelis-Menten과 Logistic — 나머지 두 형태

Michaelis-Menten

Hill 곡선의 특수형태입니다. 추정이 가장 쉽고 직관도 명료해 MMM 입문에 적합합니다. 다만 곡선이 너무 매끄러워 실제 시장의 sharp threshold를 표현하지 못할 수 있습니다.

Logistic — S자 곡선

처음에는 효율이 늘다가 어느 지점( 근처)부터 떨어지는 S자 곡선입니다. 신규 채널 진입 초기에 자주 보이는 패턴입니다. 처음 몇 주는 학습이 필요해 한계 ROAS가 낮고, 어느 정도 데이터가 쌓이면 ROAS가 가장 높아진 뒤 saturation 영역으로 들어갑니다.

PyMC로 posterior 신용구간 시각화

L·k posterior 분포와 90% CI

L과 k를 점추정(curve_fit)으로 잡으면 추정의 불확실성이 사라집니다. PyMC로 베이지안 추정하면 L과 k가 분포로 나오고, 그 분포를 saturation 곡선에 투영하면 90% 신용구간이 생깁니다.

import pymc as pm
import arviz as az
# spend_meta, contrib_meta: 위와 동일
with pm.Model() as saturation_model:
# L prior: 현재 지출 평균의 1~5배 사이
L = pm.HalfNormal("L", sigma=150e6)
# k prior: 곡선 가파름 (0.5~4 사이)
k = pm.Gamma("k", alpha=2, beta=1) # 평균=2, 분산=2
# Hill saturation
mu = (spend_meta**k) / (spend_meta**k + L**k)
# 관측 오차
sigma_obs = pm.HalfNormal("sigma_obs", sigma=0.05)
obs = pm.Normal("obs", mu=mu, sigma=sigma_obs,
observed=contrib_meta)
idata_sat = pm.sample(1000, tune=500, target_accept=0.9,
random_seed=42, return_inferencedata=True)
# 수렴 진단
summary = az.summary(idata_sat, var_names=["L", "k"])
print(summary[["mean", "sd", "hdi_3%", "hdi_97%", "r_hat"]])
# posterior 곡선 시각화
x_plot = np.linspace(5e6, 200e6, 300)
posterior_L = idata_sat.posterior["L"].values.flatten()
posterior_k = idata_sat.posterior["k"].values.flatten()
curves = np.array([
hill(x_plot, l, kk) for l, kk in zip(posterior_L[:500], posterior_k[:500])
])
plt.figure(figsize=(9, 5))
plt.fill_between(x_plot / 1e6,
np.percentile(curves, 5, axis=0),
np.percentile(curves, 95, axis=0),
alpha=0.25, color='steelblue', label='90% 신용구간')
plt.plot(x_plot / 1e6, np.median(curves, axis=0),
color='steelblue', lw=2, label='posterior 중앙값')
plt.scatter(spend_meta / 1e6, contrib_meta, color='red', zorder=5, label='관측값')
plt.xlabel("주간 지출 (백만 원)"); plt.ylabel("기여도 (정규화)")
plt.title("Hill Saturation — PyMC posterior 90% 신용구간")
plt.legend(); plt.tight_layout(); plt.show()
# → 신용구간이 넓은 지출 구간 = 데이터 부족, 의사결정 신중히

출력 해석: r_hat이 1.01 이하여야 수렴 성공입니다. hdi_3%~hdi_97%가 L의 94% 신용구간(HDI)이고, 이 구간이 현재 지출을 감싸고 있으면 “지금 L 근처에 있다”고 보고할 수 있습니다. prior 민감도 점검은 mmm-prior-elicitation을 참고하세요.

실무 — saturation 결과를 의사결정으로

분기 회의 슬라이드 한 장

각 채널의 saturation 곡선과 현재 지출 위치, 한계 ROAS를 한 plot에 그려 회의에 띄웁니다. 마케팅팀이 보면 자동으로 다음 질문이 나옵니다:

  • 어느 채널이 좌측에 있는가? (더 부어도 효율 좋음)
  • 어느 채널이 우측에 있는가? (줄여서 옮길 후보)
  • 곡선의 90% 신뢰구간이 가장 넓은 채널은? (데이터 부족, 추가 검증 필요)

채널별 saturation 파라미터 사례

아래는 성숙 이커머스 브랜드의 전형적 추정치 범위입니다(실제 fit 결과는 회사·시장마다 다름).

채널 범위 범위현재 위치판단
Meta (퍼포먼스)60M~120M1.2~1.8L 우측줄이고 다른 채널로
Naver 검색30M~60M1.0~1.5L 좌측더 부을 여지
TV 브랜드100M~250M1.5~2.5L 우측유지 (브랜드 목적)
YouTube40M~90M1.0~2.0L 근처소폭 증액 후 재관측

이 숫자를 분기 회의 슬라이드에 표로 올리고, 채널별 90% CI와 함께 제시하면 “왜 줄여야 하나” 질문에 데이터로 답할 수 있습니다.

함정 모음

  • 외삽 — 학습 구간 밖에서 곡선을 그대로 외삽하면 위험. 시뮬레이션 범위 제한 필수
  • 채널 간 spillover — TV가 search lift를 만들면 검색 곡선이 평탄해 보임. spillover 변수 추가
  • cold start — 신규 채널 첫 8~12주는 곡선 추정이 안 됨
  • 외부 충격 미반영 — 큰 캠페인·경쟁사 진입을 외생 변수로 모델에 안 넣으면 곡선이 휘어짐
  • adstock 오설정 영향 — adstock을 잘못 잡으면 saturation 파라미터도 함께 틀어집니다. mmm-adstock-deep-dive에서 adstock 먼저 검증하세요.

마치며

saturation 곡선은 MMM 결과를 의사결정으로 번역하는 가장 직접적인 통로입니다. Hill 함수를 scipy로 fit해 L·k를 추정하고, 도함수로 한계 ROAS를 계산하고, PyMC posterior로 신용구간을 표현하는 세 단계가 있어야 “왜 이 채널 줄여야 하나” 질문에 데이터로 답할 수 있습니다.

saturation 곡선이 준비되면 한계 ROAS 균등화 최적화로 자연스럽게 넘어갑니다.

다음에 읽을 글

참고

퍼포먼스 마케팅 카테고리의 다른 글

전체 보기 →