Парадигмы за горизонтом: вероятностное, дифференцируемое и квантовое программирование
Все двенадцать предыдущих статей трека объединяло молчаливое допущение: переменная содержит значение, и это значение можно напечатать. Оно может быть неизменяемым или изменяемым, спрятанным в объекте или разложенным по массивам, но это всегда нечто определённое.
Три парадигмы этой статьи ломают именно это допущение. В вероятностном программировании переменная содержит распределение. В дифференцируемом — значение плюс способ узнать, как оно отреагирует на изменение входа, то есть градиент. В квантовом — амплитуду, которую вообще нельзя прочитать, не разрушив.
Отсюда следует важное практическое обстоятельство: ломается не только семантика, ломается отладка. Поставить брейкпоинт и посмотреть значение больше нельзя — вместо этого появляются диагностики: сходимость цепей, нормы градиентов, распределение исходов измерений. Это делает три очень разные технологии похожими по инженерным ощущениям.
над не-значениями)) Вероятностное Единица — распределение Программа — генеративная модель Движок — вывод по наблюдениям Диагностика вместо print R-hat и ESS трейсплоты расхождения выборки Дифференцируемое Единица — значение и градиент Программа — вычислительный граф Движок — обратное распространение Ограничения недифференцируемые ветвления память на активации численная устойчивость Квантовое Единица — амплитуда Программа — схема унитарных операций Движок — интерференция и измерение Ограничения шум и декогеренция нет копирования состояния коррекция ошибок
Общая рамка: что каждая отнимает и что даёт
Ровно та же оптика, что в обзорной статье трека — ограничение в обмен на гарантию.
| Парадигма | Отнимает | Даёт взамен |
|---|---|---|
| Вероятностная | детерминированный ответ и дешёвое исполнение | неопределённость как первоклассную величину: не «оценка 0,7», а «0,7 плюс-минус столько, с такой вероятностью» |
| Дифференцируемая | произвольный поток управления и дискретные решения | автоматическую подгонку параметров под цель по градиенту, без ручного вывода производных |
| Квантовая | наблюдаемость промежуточного состояния и копирование данных | пространство состояний, растущее экспоненциально с числом кубитов, и алгоритмы, использующие интерференцию |
Часть I. Вероятностное программирование
Идея: программа как генеративная модель
Обычная программа отвечает на вопрос «какой результат при таких входах». Вероятностная программа описывает, как данные могли появиться, а движок отвечает на обратный вопрос: «какие параметры правдоподобны, если мы наблюдали вот это».
Прямой прогон такой программы — это сэмплирование: она порождает синтетические данные. Интересное начинается, когда вы добавляете наблюдения и просите движок «запустить программу назад».
# Псевдокод в стиле PyMC/Stan: описываем ПОРОЖДЕНИЕ данных, а не алгоритм.
# p_A ~ Beta(1, 1) # что мы знали о конверсии A до эксперимента
# p_B ~ Beta(1, 1)
# k_A ~ Binomial(n_A, p_A) # как из p_A получились наблюдения
# k_B ~ Binomial(n_B, p_B)
# observe(k_A = 118, n_A = 2000)
# observe(k_B = 152, n_B = 2010)
# infer: P(p_B > p_A), а также распределение прироста p_B - p_A
Ни одной строчки про то, как считать. Есть модель и есть наблюдения; способ вывода — забота движка. Это ровно та же сделка «отдай управление, получи оптимизацию», что в декларативной парадигме, только вместо плана запроса движок выбирает стратегию вывода.
Как это считается на самом деле
Для бета-биномиальной модели ответ известен аналитически: апостериорное распределение — снова бета, Beta(1 + k, 1 + n - k). Это редкая удача (сопряжённые распределения), и она позволяет проверить численный метод на задаче с известным ответом.
import numpy as np
rng = np.random.default_rng(42)
n_a, k_a = 2000, 118 # показов и конверсий в контроле
n_b, k_b = 2010, 152 # в варианте
# 1) Сопряжённый случай: апостериор известен в закрытой форме.
post_a = rng.beta(1 + k_a, 1 + n_a - k_a, size=200_000)
post_b = rng.beta(1 + k_b, 1 + n_b - k_b, size=200_000)
p_b_better = float((post_b > post_a).mean())
uplift = post_b - post_a
lo, hi = np.quantile(uplift, [0.025, 0.975])
print(f"P(B лучше A) = {p_b_better:.3f}")
print(f"прирост конверсии: {uplift.mean():.4f}, 95% интервал [{lo:.4f}, {hi:.4f}]")
# P(B лучше A) = 0.983
# прирост конверсии: 0.0166, 95% интервал [0.0016, 0.0316]
Ответ здесь принципиально другого сорта, чем p-value < 0.05: это распределение прироста, из которого напрямую считается ожидаемая выручка и риск ошибки. Практическая сторона A/B-экспериментов разобрана в статье A/B-тестирование, теория — в вероятности и статистике.
Как только модель перестаёт быть сопряжённой (а это происходит на второй же реальной задаче), закрытой формы нет и нужен численный вывод. Вот честная мини-реализация алгоритма Метрополиса — того самого, на котором построены все MCMC-движки:
import numpy as np
def log_posterior(p, k, n):
"""Логарифм ненормированной апостериорной плотности: приор Beta(1,1) + биномиальное правдоподобие."""
if not 0.0 < p < 1.0:
return -np.inf # вне носителя — нулевая плотность
return k * np.log(p) + (n - k) * np.log1p(-p)
def metropolis(k, n, steps=100_000, scale=0.01, seed=0):
"""Случайное блуждание с симметричным предложением. O(steps) времени, O(steps) памяти."""
rng = np.random.default_rng(seed)
p = 0.5
lp = log_posterior(p, k, n)
chain = np.empty(steps)
accepted = 0
for i in range(steps):
proposal = p + rng.normal(0.0, scale)
lp_new = log_posterior(proposal, k, n)
# Симметричное предложение -> отношение сводится к отношению плотностей.
if np.log(rng.random()) < lp_new - lp:
p, lp = proposal, lp_new
accepted += 1
chain[i] = p
return chain, accepted / steps
chain, acc_rate = metropolis(k=152, n=2010)
burn_in = chain[10_000:] # первые шаги выбрасываем: цепь ещё «идёт к цели»
print(f"среднее апостериора {burn_in.mean():.4f}, доля принятий {acc_rate:.2f}")
# среднее апостериора ~0.0761 при аналитическом 153/2012 = 0.0760 — совпадает
Три вещи, которые видно из этих двадцати строк и которые определяют весь опыт работы с вероятностным программированием:
- Стоимость. Одна оценка правдоподобия на шаг, шагов — десятки и сотни тысяч. Итоговая сложность —
O(steps × стоимость модели). Модель с миллионом наблюдений и сложным правдоподобием считается минутами и часами, а не миллисекундами. - Настройка. Слишком маленький
scale— цепь ползёт и не исследует пространство; слишком большой — почти все предложения отвергаются. Современные движки (NUTS/HMC в Stan, PyMC, NumPyro) настраивают шаг сами и используют градиент правдоподобия — да, тот самый из части II. - Проверка сходимости обязательна. Цепь не «падает с ошибкой», она молча возвращает неправильный ответ. Поэтому запускают несколько цепей и смотрят диагностики.
плюс правдоподобие"] --> ENG["Движок вывода
MCMC / NUTS / вариационный"] OBS["Наблюдения: данные эксперимента"] --> ENG ENG --> POST["Апостериорное распределение
в виде выборки"] POST --> DIAG{"Диагностика
R-hat, ESS, расхождения"} DIAG -- "не сошлось" --> FIX["Перепараметризовать модель,
сменить приоры, увеличить прогрев"] FIX --> ENG DIAG -- "сошлось" --> DEC["Решение: P(B лучше A),
ожидаемая выгода, риск"] POST --> PPC["Проверка предсказаниями:
похожи ли синтетические данные на реальные"] PPC -- "не похожи" --> FIX
Где это работает в проде
- Байесовские A/B-тесты и последовательные эксперименты — решение можно принимать по мере накопления данных, а не только в конце.
- Рейтинги игроков. TrueSkill в Xbox Live — вероятностная модель навыка с неопределённостью; отсюда и «шкала уверенности» у нового игрока.
- Маркетинг-микс и прогноз спроса — модели с иерархией по регионам и категориям, где данных на каждый сегмент мало и неопределённость надо честно переносить дальше.
- Оценка надёжности и рисков — актуарные расчёты, отказы оборудования, оценки редких событий.
- Инструменты. Stan, PyMC, NumPyro, Turing.jl, Gen.
Цена парадигмы честная: это медленно, требует статистической грамотности всей команды и даёт новый класс инцидентов — «модель не сошлась» вместо «сервис упал». Зато там, где решение принимается в условиях неопределённости и цена ошибки высока, никакая точечная оценка не заменит распределение.
Часть II. Дифференцируемое программирование
Идея: программа, у которой можно спросить производную
Дифференцируемое программирование — это стиль, в котором любая функция, написанная вами, автоматически умеет сообщать градиент по своим параметрам. Не «нейросети», а именно программа общего вида: симуляция физики, рендер, планировщик, функция потерь — что угодно, состоящее из дифференцируемых операций.
Автоматическое дифференцирование — это не численная разность ((f(x+h) - f(x)) / h, теряет точность и стоит O(n) вычислений) и не символьное дифференцирование (взрыв выражений). Это применение правила цепочки к записанному графу операций, с точностью до машинного эпсилон.
Ключевой инженерный факт, объясняющий, почему обучение вообще возможно: для функции с миллионом входов и одним скалярным выходом обратный проход стоит примерно столько же, сколько прямой — независимо от числа параметров.
| Режим | Стоимость для f: R^n -> R^m |
Память | Когда применяют |
|---|---|---|---|
| Прямой (forward) | примерно n прогонов |
маленькая, потоковая | мало входов, много выходов; якобиан-векторные произведения |
| Обратный (reverse) | примерно m прогонов |
хранит промежуточные значения | много входов, скалярный выход — то есть всё обучение |
Отсюда и вся конструкция машинного обучения: параметров миллионы (n огромно), потеря одна (m = 1), поэтому используется обратный режим — он же обратное распространение ошибки.
Мини-реализация, которая помещается в голову
class Value:
"""Скаляр, который помнит, как он был получен. Достаточно для полноценного autodiff."""
def __init__(self, data, children=(), op=""):
self.data = data
self.grad = 0.0
self._backward = lambda: None # как передать градиент родителям
self._prev = set(children)
self._op = op
def __add__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data + other.data, (self, other), "+")
def _backward():
# d(a+b)/da = 1, d(a+b)/db = 1 — градиент проходит насквозь
self.grad += out.grad
other.grad += out.grad
out._backward = _backward
return out
def __mul__(self, other):
other = other if isinstance(other, Value) else Value(other)
out = Value(self.data * other.data, (self, other), "*")
def _backward():
# d(a*b)/da = b, d(a*b)/db = a
self.grad += other.data * out.grad
other.grad += self.data * out.grad
out._backward = _backward
return out
def relu(self):
out = Value(self.data if self.data > 0 else 0.0, (self,), "relu")
def _backward():
# производная разрывна в нуле — берём субградиент
self.grad += (out.data > 0) * out.grad
out._backward = _backward
return out
def backward(self):
"""Топологическая сортировка графа и один обратный проход. O(V + E)."""
order, visited = [], set()
def build(v):
if v not in visited:
visited.add(v)
for child in v._prev:
build(child)
order.append(v)
build(self)
self.grad = 1.0 # d(результат)/d(результат) = 1
for v in reversed(order):
v._backward()
# Проверка: f(a, b) = (a * b + b).relu(), a = -3, b = 4
a, b = Value(-3.0), Value(4.0)
f = (a * b + b).relu()
f.backward()
print(f.data, a.grad, b.grad) # 0.0 0.0 0.0 — в отрицательной ветви ReLU градиента нет
Сорок строк дают ровно ту механику, что лежит в основе PyTorch и JAX: граф строится по ходу вычисления, обратный проход идёт по нему в обратном топологическом порядке. Всё остальное в промышленных фреймворках — производительность, работа с тензорами, распределённость и компиляция графа.
a * b"] B["b = 4"] --> M M --> S["сложение
+ b"] B --> S S --> R["relu"] R --> L["потеря L"] L -. "dL/dL = 1" .-> R R -. "субградиент 0 или 1" .-> S S -. "проходит насквозь" .-> M M -. "dL/da = b * grad" .-> A M -. "dL/db = a * grad" .-> B
Где парадигма упирается в стену
- Недифференцируемые операции.
argmax, сортировка, ветвление по данным, дискретный выбор — производной нет. Обходят релаксациями (Gumbel-softmax, «мягкий» argmax), оценщиками градиента для дискретных величин или прямым пропусканием градиента (straight-through). - Память. Обратный режим обязан хранить промежуточные значения всего прямого прохода. Для глубокой сети это гигабайты; лечится градиентным чекпойнтингом — обмен памяти на время (пересчитать часть прямого прохода заново).
- Численная устойчивость. Наивные формулы взрываются:
log(sum(exp(x)))считают через сдвиг на максимум, деление на почти-ноль убивает градиент. Это ровно те же проблемы, что в численных методах. - Поток управления против компиляции. Динамический граф (PyTorch) удобно отлаживать; трассируемый и компилируемый (JAX) быстрее, но требует статических форм и запрещает произвольные
ifпо значениям тензоров.
Не только нейросети
Стоит запомнить, что дифференцируемость — свойство программы, а не только модели:
- Дифференцируемая физика — подбор параметров симуляции под наблюдаемое поведение.
- Дифференцируемый рендеринг — восстановление геометрии и материалов сцены по изображениям.
- Оптимизация систем — обучаемые контроллеры, параметры планировщиков, настройка гиперпараметров симуляций.
- Научные вычисления — JAX, Zygote.jl, Enzyme дифференцируют обычный код, а не специальный DSL.
Практическая часть обучения моделей — в статьях Перцептрон и обратное распространение и Обучение и оптимизация; общий взгляд на постановку задач — в треке по машинному обучению.
Часть III. Квантовое программирование
Модель вычисления
Классический бит — 0 или 1. Кубит описывается парой комплексных амплитуд: состояние α|0⟩ + β|1⟩, где |α|² + |β|² = 1. Регистр из n кубитов описывается 2^n амплитудами — тридцать кубитов это миллиард комплексных чисел, и именно поэтому симуляция на классической машине упирается в память.
Три правила, которые определяют всё программирование под такую машину:
- Операции обратимы. Любой гейт — унитарное преобразование; вычисление можно прокрутить назад. Никакого «затереть переменную».
- Измерение разрушает. Прочитав кубит, вы получаете 0 или 1 с вероятностью
|α|²или|β|², а суперпозиция схлопывается. Промежуточное состояние принципиально не наблюдаемо. - Копировать нельзя. Теорема о запрете клонирования: универсального
copy(qubit)не существует. Это ломает привычные приёмы вроде «сохраню на всякий случай».
Самое частое заблуждение: «квантовый компьютер перебирает все варианты параллельно». Суперпозицию действительно можно подготовить над всеми входами, но измерение вернёт один случайный результат. Полезные алгоритмы устроены иначе: они организуют интерференцию так, чтобы амплитуды неправильных ответов гасили друг друга, а правильного — усиливались. Квантовое программирование — это не про перебор, это про конструирование интерференции.
Схема на картинке — «hello world» этой парадигмы: гейт Адамара переводит первый кубит в равную суперпозицию, CNOT связывает второй с первым, измерение даёт 00 или 11 примерно поровну — и никогда 01 или 10. Это запутанность: результаты скоррелированы, хотя каждый по отдельности случаен.
# Qiskit: схема — это данные, которые исполняются на симуляторе или на железе.
from qiskit import QuantumCircuit
qc = QuantumCircuit(2, 2)
qc.h(0) # суперпозиция на первом кубите
qc.cx(0, 1) # запутывание: CNOT с управляющим 0 и целевым 1
qc.measure([0, 1], [0, 1])
# Результат — не значение, а гистограмма исходов по N прогонам ("выстрелам"):
# {'00': ~50%, '11': ~50%} — состояний '01' и '10' не будет.
Обратите внимание на программную модель: квантовая программа — это данные (схема), которые собирает и запускает классический хост, получая распределение исходов. Отладка — это статистика по прогонам и симулятор для маленьких размерностей.
Что реально умеют алгоритмы
| Алгоритм | Выигрыш | Практический смысл |
|---|---|---|
| Гровер, поиск в неструктурированном множестве | квадратичный: около корня из N вместо N | ускорение переборных задач; на реальном железе съедается накладными расходами |
| Шор, факторизация и дискретный логарифм | экспоненциальный | угроза RSA и эллиптической криптографии — при наличии большого отказоустойчивого компьютера |
| Симуляция квантовых систем | экспоненциальный для ряда задач | химия, материалы — исходная мотивация Фейнмана и самое вероятное первое применение |
| Вариационные схемы VQE и QAOA | не доказан | гибридные эвристики для оптимизации и химии на нынешнем шумном железе |
Как выглядит реальность 2020-х
Текущая эпоха называется NISQ — шумные квантовые устройства промежуточного масштаба (термин ввёл Джон Прескилл в статье 2018 года). Кубиты декогерируют за микросекунды, гейты ошибаются, и глубина схемы ограничена этим сверху. Коррекция ошибок принципиально решает проблему, но платит накладными расходами: один логический кубит — это порядка тысячи физических при современных подходах вроде поверхностного кода.
Отсюда доминирующая программная модель — гибридная: классический оптимизатор крутит параметры короткой квантовой схемы.
вся «логика» остаётся классической
Что из этого касается инженера уже сегодня — ровно один пункт, и он не про написание квантовых программ. Угроза «собрать шифротрафик сейчас, расшифровать потом» реальна для данных с долгим сроком секретности, поэтому в 2024 году NIST стандартизировал постквантовые алгоритмы (FIPS 203 ML-KEM, FIPS 204 ML-DSA, FIPS 205 SLH-DSA), и миграция на них — обычная инженерная задача с инвентаризацией, сроками и совместимостью. Практика — в статье Прикладная криптография и Транспортная безопасность.
Как эти три парадигмы возникали
Что общего и чему это учит
| Вопрос | Вероятностное | Дифференцируемое | Квантовое |
|---|---|---|---|
| Единица вычисления | распределение | значение вместе с градиентом | амплитуда |
| Что пишет человек | генеративную модель | обычную программу из дифференцируемых операций | схему унитарных операций |
| Что делает движок | вывод по наблюдениям | обратный проход по графу | исполнение схемы и измерение |
| Главный ресурс | время на сэмплирование | память на активации | глубина схемы до декогеренции |
| Как отлаживают | R-hat, ESS, проверка предсказаниями | нормы градиентов, поиск NaN, проверка конечными разностями | симулятор, статистика исходов, томография |
| Главная ловушка | молча не сошлось | молча обучилось не тому | шум съел сигнал |
Общий вывод, полезный далеко за пределами этих трёх технологий: как только промежуточное значение перестаёт быть наблюдаемым, тесты на равенство заменяются статистическими проверками. Это уже происходит в обычной разработке — например, при работе с LLM, где ответ недетерминирован и проверяется свойствами и оценками, а не сравнением строк. Так что навык «отлаживать без брейкпоинта» из экзотического становится базовым.
Типичные ошибки
- Использовать вероятностное программирование там, где хватает формулы. Для бета-биномиальной модели MCMC — это тысячекратный перерасход на задаче с закрытым решением.
- Игнорировать диагностику сходимости. Цепь без проверки
R-hatдаёт уверенный неправильный ответ, а не ошибку. - Считать autodiff волшебством. Недифференцируемая операция в середине графа обнуляет градиент, и модель «учится» ровно нулю.
- Забывать про память в обратном режиме. Внезапный OOM на длинной последовательности — почти всегда сохранённые активации.
- Верить в «квантовый параллелизм». Без интерференции суперпозиция не даёт ничего: измерение вернёт один случайный вариант.
- Планировать «перевести систему на квантовые вычисления». Осмысленное действие сегодня — инвентаризация криптографии и план миграции на постквантовые алгоритмы.
- Смешивать классы задач. Вероятностная модель отвечает на вопрос о неопределённости, градиентная оптимизация — о подгонке параметров. Подменять одно другим — распространённый способ получить уверенную чушь.
Мини-итог
- Три парадигмы ломают общее допущение всего остального трека: значение перестаёт быть наблюдаемым. Это меняет не только семантику, но и инструменты отладки.
- Вероятностная программа описывает порождение данных; движок решает обратную задачу. Ответ — распределение, из которого считаются риск и выгода, а не одно число.
- MCMC стоит
O(шагов × стоимость модели)и требует диагностик: несходимость не падает, а тихо врёт. - Автоматическое дифференцирование — это правило цепочки по графу вычислений; обратный режим даёт градиент по миллионам параметров за стоимость нескольких прямых проходов, платя памятью.
- Дифференцируемость — свойство программы, а не только нейросети: физика, рендеринг, контроллеры.
- Квантовое вычисление — про интерференцию, а не про перебор; ограничения — необратимость измерения, запрет копирования, шум и стоимость коррекции ошибок.
- Единственное, что требует действий от обычного инженера сегодня, — миграция на постквантовую криптографию для данных с длинным сроком секретности.
Источники
- Andrew Gelman et al. Bayesian Data Analysis, 3-е изд. — страница книги, стандартный учебник по байесовскому анализу.
- Stan User’s Guide и PyMC: Diagnosing Biased Inference — практика вывода и диагностик.
- Atilim Gunes Baydin et al. Automatic Differentiation in Machine Learning: a Survey — обзор режимов и реализаций.
- Andrej Karpathy. micrograd — тот же скалярный autodiff, что разобран выше, в ста строках.
- JAX documentation — автодифференцирование обычного Python-кода.
- Michael Nielsen, Isaac Chuang. Quantum Computation and Quantum Information — канонический учебник; конспект Nielsen онлайн.
- John Preskill. Quantum Computing in the NISQ era and beyond, 2018.
- Qiskit textbook и NIST Post-Quantum Cryptography — практика схем и стандарты постквантовых алгоритмов.
Что дальше
На этом трек «Парадигмы разработки» закончен: от карты парадигм через девять классических моделей и метод их смешения до автоматов, данных, модулей и трёх парадигм за горизонтом. Главное, что стоит унести, — оптика: любое инженерное решение читается как выбор ограничения и цены, которую вы за него платите.
Куда идти дальше, в зависимости от того, чего не хватает:
- Не хватает фундамента под кодом. Алгоритмы и Структуры данных — что именно делает программа и за какую цену.
- Не хватает правил на уровне модуля. Принципы разработки — SOLID, связность, зацепление и то, как они следуют из того же обмена «свобода за гарантию».
- Нужны готовые решения типовых задач. Паттерны проектирования, Архитектурные паттерны и DDD для сложного домена.
- Хочется закрепить парадигмы практикой в языке. Go — CSP и минимализм; Elixir — акторы и ФП; TypeScript — типы как инструмент проектирования; Rust — владение и трейты.
- Интересны парадигмы этой статьи всерьёз. Машинное обучение, Нейронные сети и Математика — вероятность, линейная алгебра, численные методы.
- Нужен маршрут целиком. Общая карта портала и порядок изучения — в дорожной карте.