Анализ чувствительности и распространение неопределённости

У вас есть модель, которая выдаёт одно красивое число — «доход 20 495 рублей». Но входные параметры вы знаете неточно. Какой из них способен разрушить ваш прогноз сильнее всего, и насколько широк интервал, в котором реально окажется результат?

Анализ чувствительности — это исследование того, как изменение каждого входного параметра модели по отдельности влияет на её выход. Он отвечает на вопрос «к чему модель чувствительна, а к чему — почти безразлична».

Любая модель — это функция: вы подаёте на вход цену, темп роста, отток клиентов, а на выходе получаете доход. В реальности ни один из входов не известен точно: цена ещё обсуждается, темп роста — оптимистичная оценка, отток измерен на маленькой выборке. Если вы строите бизнес-план на единственном числе, вы обманываете и себя, и инвесторов. Анализ чувствительности и распространение неопределённости — два инструмента, которые превращают «одно число» в честную картину рисков.

Зачем это нужно на практике. Во-первых, чтобы понять, какой параметр стоит измерять точнее всего: нет смысла шлифовать цену до копейки, если результат в основном определяется оттоком. Во-вторых, чтобы сообщать решение в виде интервала, а не точки — это и честнее, и устойчивее к критике.

Блок A. Чувствительность «по одному параметру»

Возьмём простую модель дохода стартапа за год. Стартуем со 100 пользователями; каждый месяц их число растёт на rate и одновременно часть уходит (churn), а каждый оставшийся приносит price рублей. Идея анализа: зафиксировать все параметры на базовых значениях и по очереди «качнуть» каждый от его нижней границы к верхней, измеряя размах выхода.

def model(price, rate, churn):
    users = 100.0
    revenue = 0.0
    for month in range(12):
        users = users * (1 + rate) * (1 - churn)
        revenue += users * price
    return revenue

base = model(10, 0.2, 0.1)
print("Базовый доход:", round(base, 1))
for name, lo, hi in [("price", 8, 12), ("rate", 0.15, 0.25), ("churn", 0.05, 0.15)]:
    if name == "price":
        r_lo = model(lo, 0.2, 0.1); r_hi = model(hi, 0.2, 0.1)
    elif name == "rate":
        r_lo = model(10, lo, 0.1); r_hi = model(10, hi, 0.1)
    else:
        r_lo = model(10, 0.2, lo); r_hi = model(10, 0.2, hi)
    print(f"{name:>6}: {r_lo:>8.0f} .. {r_hi:>8.0f}  размах={abs(r_hi - r_lo):>8.0f}")

Вывод:

Базовый доход: 20495.3
 price:    16396 ..    24594  размах=    8198
  rate:    15113 ..    27989  размах=   12876
 churn:    31089 ..    13680  размах=   17408

Читаем таблицу. Колонка «размах» — это и есть мера чувствительности: насколько широко гуляет доход, когда мы качаем данный параметр в его реалистичном диапазоне. У price размах 8198, у rate — 12876, а у churn — целых 17408. Вывод однозначен: отток клиентов критичнее всего. Обратите внимание и на знак: для churn доход при низком значении выше (31089 при churn=0.05), чем при высоком (13680 при churn=0.15) — больший отток съедает доход.

Почему это называют «торнадо-анализом»

Если нарисовать горизонтальные полоски «низкое значение — высокое значение» для каждого параметра и отсортировать их по длине, самая длинная окажется сверху, самая короткая — снизу. Получается фигура, сужающаяся книзу, — похожая на воронку торнадо. Это стандартный способ визуально расставить параметры по важности.

churn  |==================|   (самый влиятельный)
rate   |=============|
price  |========|             (наименее влиятельный)

Блок B. Распространение неопределённости через Монте-Карло

Анализ «по одному параметру» отвечает на вопрос «что важнее», но не отвечает на «каков итоговый разброс, когда все входы неточны одновременно». Для этого мы задаём каждый вход не числом, а интервалом, и многократно прогоняем модель со случайными значениями из этих интервалов. Это и есть распространение неопределённости методом Монте-Карло.

Распространение неопределённости — это перенос разброса входных величин на выход модели: на входе у нас распределения, и на выходе мы получаем тоже распределение, а не одно число.

import random, statistics
random.seed(42)

def model(price, rate, churn):
    users = 100.0
    revenue = 0.0
    for month in range(12):
        users = users * (1 + rate) * (1 - churn)
        revenue += users * price
    return revenue

def trial():
    return model(random.uniform(8, 12), random.uniform(0.15, 0.25), random.uniform(0.05, 0.15))

results = [trial() for _ in range(10000)]
rs = sorted(results)
print(f"Среднее: {statistics.mean(results):.0f}")
print(f"Стд: {statistics.pstdev(results):.0f}")
print(f"5-й перцентиль: {rs[500]:.0f}")
print(f"95-й перцентиль: {rs[9500]:.0f}")

Вывод:

Среднее: 21498
Стд: 6979
5-й перцентиль: 12237
95-й перцентиль: 34887

Что мы получили вместо единственного «20 495»? Среднее около 21 498, стандартное отклонение почти 7 000 — это и есть масштаб неопределённости. А перцентили дают самый ценный результат: 5-й перцентиль (12 237) и 95-й (34 887) очерчивают 90% доверительный интервал. Честная формулировка для инвестора звучит так: «с вероятностью около 90% годовой доход окажется между 12 и 35 тысячами рублей». Это в разы информативнее, чем «доход будет 20 495».

Как работает под капотом

Монте-Карло здесь — это просто закон больших чисел в действии. Каждый вызов trial() берёт по одному случайному значению из каждого входного интервала (равномерно, через random.uniform) и прогоняет модель. Накопив 10 000 таких прогонов, мы получаем эмпирическое распределение выхода. Среднее этого распределения оценивает «ожидаемый» доход, а отсортированный список rs позволяет читать перцентили напрямую: элемент с индексом 500 из 10 000 — это 5-й перцентиль, индекс 9500 — 95-й. random.seed(42) фиксирует поток случайных чисел, поэтому вывод воспроизводим до последней цифры.

Важная тонкость: чувствительность (Блок A) и распространение (Блок B) дополняют друг друга. Первый говорит, куда смотреть (сократите отток — и разброс схлопнется сильнее всего), второй — насколько широка итоговая неопределённость с учётом всех входов сразу.

Частые ошибки

  • Качать параметр в нереалистичном диапазоне. Если границы lo/hi взяты «с потолка», размах в торнадо-анализе ничего не значит. Диапазоны должны отражать реальную неопределённость каждого входа.
  • Считать чувствительность «по одному» полной картиной. Метод OAT (one-at-a-time) игнорирует взаимодействия параметров — он не заметит, что эффект rate усиливается при низком churn. Для этого и нужен Монте-Карло.
  • Брать слишком мало прогонов. На 100 итерациях перцентили будут шуметь. 10 000 — разумный минимум для устойчивых хвостов распределения.
  • Докладывать только среднее. Среднее без разброса скрывает риск: «21 498» звучит уверенно, но 5-й перцентиль 12 237 предупреждает о плохом сценарии.
  • Путать перцентиль со стандартным отклонением. При несимметричном распределении интервал «среднее ± 1.96·стд» и интервал по перцентилям различаются — для перекошенных выходов перцентили честнее.

Итоги

  • Анализ чувствительности «по одному параметру» (торнадо-анализ) показывает, какой вход критичнее всего — здесь это отток клиентов (размах 17408).
  • Колонка «размах» — прямая мера влияния параметра; сортировка по ней даёт фигуру торнадо.
  • Распространение неопределённости через Монте-Карло превращает интервалы входов в распределение выхода.
  • Перцентили дают честный доверительный интервал: «доход с 90% уверенностью между 12 и 35 тысячами».
  • Два метода дополняют друг друга: один указывает, что измерять точнее, другой — насколько широк итоговый риск.
Проверьте себя
1. В торнадо-анализе из блока A какой параметр оказался самым влиятельным на доход?
Aprice (размах 8198)
Brate (размах 12876)
Cchurn (размах 17408)
Dвсе три одинаково
2. Зачем в блоке B мы прогоняем модель 10 000 раз со случайными входами, а не считаем один раз?
AЧтобы код работал дольше
BЧтобы получить распределение выхода и честный доверительный интервал, а не одно число
CЧтобы случайно угадать точный ответ
DЭто требование библиотеки statistics
3. Что означают 5-й и 95-й перцентили (12237 и 34887) в выводе блока B?
AМинимальный и максимальный возможный доход
BГраницы, между которыми доход окажется примерно с 90% вероятностью
CСреднее и стандартное отклонение
DДоход в первый и последний месяц
4. Почему анализ «по одному параметру» (OAT) недостаточен сам по себе?
AОн слишком медленный
BОн не учитывает взаимодействия параметров, когда несколько входов неточны одновременно
CОн требует библиотеки random
DОн всегда даёт неверный ответ