
С чего все началось
Я хотел просто пожарить кесадилью. В холодильнике лежали зеленые оливки (солено-кислые), сулугуни и фарш, а на полке — консервированная кукуруза. И вот стою я над сковородкой и думаю: а оливки с кукурузой вообще сочетаются? А сулугуни не пересолит блюдо вместе с оливками? Сколько чего вообще класть?
В любой другой ситуации я бы загуглил рецепт. Но не тут-то было, я же великий комбинатор оптимизатор, и у меня в голове сразу всплыло: «это же задача оптимизации». Тем же вечером у меня был ноутбук с обученной нейросетью вместо ужина. Рассказываю, как дошел до жизни такой, и как из этого внезапно получился реально вкусный рецепт.
Вкус — это вектор
Базовая идея простая. Любой вкус раскладывается на оси.
Я взял семь: соль, кислота, сладость, горечь, умами, острота, жир.
Тогда каждый ингредиент — это точка в 7-мерном пространстве. Я закодировал свою кладовую руками (да, по ощущениям и кулинарному имхо, это самая субъективная часть):
Ингредиент |
соль |
кислота |
сладкое |
горечь |
умами |
острота |
жир |
фарш гов.-свин. |
3 |
0 |
1 |
0 |
8 |
0 |
7 |
сулугуни |
6 |
2 |
1 |
0 |
5 |
0 |
6 |
зел. оливки |
8 |
5 |
0 |
2 |
4 |
0 |
3 |
кукуруза |
0 |
0 |
7 |
1 |
2 |
0 |
1 |
карамел. лук |
1 |
1 |
8 |
1 |
2 |
0 |
2 |
помидор |
1 |
4 |
3 |
0 |
3 |
0 |
0 |
лайм |
0 |
9 |
1 |
2 |
0 |
0 |
0 |
То есть вкус готового блюда — это просто взвешенная сумма векторов ингредиентов:
import numpy as np # оси: соль, кислота, сладость, горечь, умами, острота, жир ING = { "фарш": np.array([3, 0, 1, 0, 8, 0, 7.0]), "сулугуни": np.array([6, 2, 1, 0, 5, 0, 6.0]), "оливки": np.array([8, 5, 0, 2, 4, 0, 3.0]), "кукуруза": np.array([0, 0, 7, 1, 2, 0, 1.0]), "лук": np.array([1, 1, 8, 1, 2, 0, 2.0]), "помидор": np.array([1, 4, 3, 0, 3, 0, 0.0]), "лайм": np.array([0, 9, 1, 2, 0, 0, 0.0]), } V = np.stack(list(ING.values())) def profile(amounts): # amounts — сколько каждого кладем return amounts @ V

Каталог готовых ИИ-моделей
Сервис для запуска и управления LLM в облаке Selectel. Выберите модель, конфигурацию и получите готовый эндпоинт для работы с ней.
Уравнение баланса (главный инсайт)
Проблема зеленых оливок: они бьют по двум осям — соль и горечь. Если бросить их в сыр и завернуть, получится соленое нечто, которое невозможно есть.
Как это лечится физиологически? Сладкое и кислое подавляют восприятие соли и горечи. Тот же принцип объясняет, почему в горький кофе кладут сахар, а в соленую карамель, соответственно, соль.
Отсюда метрика, вокруг которой крутится вся идея:
def rho(p): # (соль + горечь) / (кислота + сладость), цель ≈ 1.0 return (p[0] + p[3]) / (p[1] + p[2] + 1e-9)
ρ ≫ 1 → солено-горький перекос, есть невозможно
ρ ≈ 1 → соль и горечь «пойманы» сладким и кислым
ρ ≪ 1 → приторно/кисло, уехали в десерт
Голая версия «сыр + оливки» дает ρ = 2.0. Цель — притянуть к единице, добавив сладость (кукуруза, карамелизованный лук) и кислоту (лайм, помидор). Казалось бы, все, идея дошла до логического конца, иди жарь...
«А давай нейросеть»
Слишком уж эта матрица вкусов напоминала мне нейронку, и план созрел красивый: обучить модель на классических, проверенных временем рецептах отличать гармоничное блюдо от случайного мусора, а потом запустить ее в обратную сторону. По сути инверсная готовка.
Датасет собрал на коленке. Позитивы — это 14 классических блюд, закодированных как вкусовые профили: маргарита, болоньезе, путанеска, цезарь, том-ям, паэлья, шакшука, чили кон карне и компания.
Негативы — это случайные миксы ингредиентов и «шипы», когда одна ось задрана в потолок, а остальные на нуле.
Сеть самая обычная, 7 → 16 → 1, на numpy:
def sigmoid(z): return 1 / (1 + np.exp(-z)) W1 = rng.normal(0, 0.5, (7, 16)) b1 = np.zeros(16) W2 = rng.normal(0, 0.5, (16, 1)) b2 = np.zeros(1) def forward(X): a1 = np.tanh(X @ W1 + b1) return sigmoid(a1 @ W2 + b2), a1
Тут на секунду остановимся, чтобы все понимали, что тут происходит. На входе семь чисел, мои оси вкуса. Дальше один скрытый слой из 16 нейронов с tanh, на выходе — одно число от 0 до 1 через сигмоиду. Читаем как «насколько это похоже на нормальную еду»: 1 — это гармония, 0 — это абсолютно несъедобно. И все, это самый базовый перцептрон, который влезает в десяток строк.
Учится через обычный backprop. То есть сеть смотрит на блюдо, выдает догадку. Сравнивает догадку с правильным ответом (1 для классики, 0 для мусора), считает ошибку. Backprop говорит, в какую сторону шевельнуть каждый вес, чтобы ошибка чуть уменьшилась. Сетка шевелит, и повторяет всего лишь 4 000 раз. И к концу сеть перестает путать борщ с белым шумом.
Датасет, негативы и обучение
import numpy as np rng = np.random.default_rng(42) AXES = ["соль","кислота","сладость","горечь","умами","острота","жир"] N = len(AXES) def normalize(p): # профиль -> сумма 1 s = p.sum() return p / s if s > 0 else p # Функция оценки гармонии def harmony(p): out, _ = forward(normalize(p).reshape(1, -1)) return float(out.item()) # ПОЗИТИВЫ: 14 классических блюд как вкусовые профили CLASSICS = { "маргарита": [3,3,2,0,5,0,5], "греч.салат": [5,4,1,2,3,0,4], "болоньезе": [3,2,2,0,7,1,6], "тако": [4,4,3,0,6,3,5], "путанеска": [6,4,1,2,6,2,4], "цезарь": [5,3,1,1,6,0,6], "карбонара": [5,1,1,0,7,1,7], "том-ям": [4,6,3,1,5,5,3], "хумус": [3,3,1,1,4,0,6], "рагу": [3,3,4,1,5,1,4], "чили": [4,3,3,1,7,4,5], "паэлья": [4,2,2,0,6,1,4], "шакшука": [4,4,3,0,5,2,4], "том-кха": [4,4,4,0,5,3,5], } pos = np.array([normalize(np.array(v, float)) for v in CLASSICS.values()]) # НЕГАТИВЫ: «не еда», 200 штук, два типа поровну def random_negative(): if rng.random() < 0.5: # 1) абсурдный микс: случайные порции случайного подмножества a = rng.random(len(ING)) * (rng.random(len(ING)) < 0.5) p = profile(a) return None if p.sum() == 0 else normalize(p) else: # 2) «шип»: все оси низкие, одна задрана в потолок v = rng.random(N) * 0.3 v[rng.integers(N)] = 1.0 + rng.random() return normalize(v) neg = [] while len(neg) < 200: s = random_negative() if s is not None: neg.append(s) neg = np.array(neg) X = np.vstack([pos, neg]) y = np.array([1.0]*len(pos) + [0.0]*len(neg)).reshape(-1, 1) # ОБУЧЕНИЕ: обычный backprop, 4000 эпох lr = 0.5 for epoch in range(4000): out, a1 = forward(X) out = np.clip(out, 1e-7, 1 - 1e-7) loss = -np.mean(y*np.log(out) + (1 - y)*np.log(1 - out)) d2 = (out - y) / len(X) dW2 = a1.T @ d2; db2 = d2.sum(0) dz1 = (d2 @ W2.T) * (1 - a1**2) dW1 = X.T @ dz1; db1 = dz1.sum(0) W2 -= lr*dW2; b2 -= lr*db2 W1 -= lr*dW1; b1 -= lr*db1
Точные loss и точность немного плавают от состава кладовой и порядка генерации случайных чисел, так что может выйти 96–97%, а не ровно мои 97.20%. Но на мораль это не влияет, негативы тут все равно отделяются легко.
В итоге я увидел loss=0,0825, точность=97,20%
97% на отделении нормальной еды от треша. Сеть работает, и я уже мысленно набирал заголовок «ИИ изобрел идеальный рецепт».
А теперь, прежде чем праздновать, две оговорки. Напишу их сам, пока их не написали за меня.
Первая. Эти 97% почти ничего не стоят. Негативы у меня — это случайный шум и блюда с одной задранной осью. Отличить такое от настоящей еды может и линейка, не то что нейросеть. Так что 97% значит всего лишь «сеть сносно отделяет еду от шума». Запомните это число, дальше я покажу, насколько оно пустое.
Вторая, и она важнее. Позитивы, те самые 14 блюд, я закодировал руками, и, самое главное, по своему вкусу. Значит сеть выучила не вкус вообще, а мой вкус. То бишь я построил прокси собственного вкуса и сейчас отдам его оптимизатору. Дальше посмотрим, что оптимизатор делает с любым прокси, даже с честно сделанным.
Что выучила сеть
Прежде чем доверять модели ужин, я решил заглянуть внутрь и посмотреть, что сеть любит, а что ненавидит. Делается это в лоб: берем сбалансированный профиль, по очереди чуть-чуть добавляем каждую ось и смотрим, как меняется оценка. Растет — сеть награждает ось, падает — штрафует. Это численная производная гармонии по каждой оси:
base = normalize(np.array([4, 3, 3, 1, 5, 1, 4.0])) # типичный баланс eps = 1e-3 for i, ax in enumerate(AXES): d = base.copy() d[i] += eps # чуть добавили одну ось g = (harmony(d) - harmony(base)) / eps # как изменилась оценка
И вот что получилось:
соль: +1.09 (награждает)
кислота: +0.18 (награждает)
сладость: -3.54 (штрафует)
горечь: -8.75 (штрафует сильно)
умами: +1.39 (награждает)
острота: +6.88 (награждает)
жир: +0.17 (награждает)
То есть сеть лютой ненавистью ненавидит горечь (−8.75). Логично, в классике горечь почти всегда фоновая.
Любит умами, жир и соль, потому что любимые человечеством блюда жирные и насыщенные (кто бы сомневался).
Обожает остроту (+6.88) — в датасете полно острых хитов (том-ям, тако, чили). А я хотел неострую кесадилью. Первый звоночек, что у нас с моделью разные планы на вечер.
Подозрительно относится к сладости (−3.54) — сладкое в несладком блюде, по всей видимости, она читает как «что-то не так».
И вот последний пункт был бомбой замедленного действия.
Reward hacking за моей сковородкой
Теперь инверсная готовка. До этого я спрашивал сеть «оцени вот это блюдо». Далее переворачиваю вопрос: «придумай количества ингредиентов с максимальной оценкой». Стартовые количества задаю сам, где-то в середине допустимых диапазонов.
Дальше дело алгоритма: на каждом шаге он смотрит, в какую сторону двигать ингредиенты, чтобы оценка подросла (больше сыра? меньше кукурузы?), делает шаг туда и сразу обрезает результат до разумных рамок (нельзя же класть минус два помидора или килограмм лайма). Вот это обрезание и называется проекция. Запускаю на 600 шагов. По сути это градиентный подъем: тот же градиентный спуск, только наоборот, лезем вверх к максимуму оценки.
lo = np.array([1.0, 1.0, 0.3, 0.2, 0.2, 0.0, 0.2]) # минимумы порций hi = np.array([3.0, 2.5, 1.2, 1.2, 1.5, 1.0, 1.0]) # максимумы a = (lo + hi) / 2 # старт посередине step = 0.05 for _ in range(600): grad = np.zeros(len(a)) f0 = objective(a) for i in range(len(a)): # численный градиент по каждой порции da = a.copy() da[i] += 1e-3 grad[i] = (objective(da) - f0) / 1e-3 a = np.clip(a + step * grad, lo, hi) # шаг вверх + проекция в рамки # objective — это оценка сети с мягким штрафом за дисбаланс
И абра-кадабра, модель, придумай мне идеальную кесадилью:
фарш: 1.19
сулугуни: 2.50 ← уперлась в максимум
оливки: 0.91
кукуруза: 0.20 ← минимум
лук: 0.20 ← минимум
помидор: 0.25 ← минимум
лайм: 0.20 ← минимум
Сеть выкрутила сыр на максимум и задавила в пол ровно те ингредиенты, которые балансируют оливки: кукурузу, лук, помидор, лайм. Она хотела приготовить мне солено-жирно-умами питту с сыром.
И вот контрольный выстрел. Я попросил сеть оценить напрямую две версии: питту из сыра с оливками и сбалансированную — ту самую, которую я в итоге и приготовил:
одна и та же сеть, две тарелки:
harmony(profile(naive)) # сыр + оливки, ρ=2.0 → 0.5845
harmony(profile(final)) # мой ручной баланс, ρ≈1.0 → 0.1320
простая (только сыр + оливки): 58.45%
сбалансированная (мой будущий фаворит): 13.20%
То есть блюдо, которое потом возьмет на дегустации твердые 9/10, нейросеть оценила в 13%. А соленую питту, от которой должно воротить, в 58%, почти вчетверо выше. Метрика не просто кривая, на моем блюде она отрицательно скоррелирована с тем, что реально вкусно.
Почему так? Возвращаемся в пункт Что выучила сеть: она штрафует сладость и обожает остроту. А мой баланс держится ровно на сладости (кукуруза, карамелизованный лук) и принципиально не содержит остроты. Для модели, выросшей на острой насыщенной классике, мой вариант выглядит как «пресная сладковатая ерунда». Она выучила «вкусное = жирное + соленое + умами + остренькое», и теперь бьет меня этим по голове. Зачем модели овощи, если можно больше сыра?
Это классический закон Гудхарта во всей красе: «когда метрика становится целью, она перестает быть хорошей метрикой». Я дал модели прокси («похоже на классическое блюдо»), она честно его оптимизировала, и проигнорировала настоящую цель («чтобы было не противно есть»). Та же история, что с агентами, которые вместо прохождения уровня игры учатся вечно собирать одну монетку в цикле. Только у меня вместо монетки сыр.
Как человек победил модель
Решение оказалось не «выкинуть нейросеть», а добавить ей интерпретируемый намордник — тот самый ρ из части 2, как регуляризатор:
def objective(a): p = profile(a) return harmony(p) - 0.15 * (rho(p) - 1.0) ** 2 # штраф за дисбаланс
Плюс мои ограничения: никакой остроты, сыр не больше разумного, овощи не ниже порога. Это, по сути, RLHF на минималках: модель предлагает, человек правит функцию награды под то, что реально хочет съесть.
Этот штраф уже стоял в оптимайзере — и все равно сеть дотащила баланс лишь до ρ = 1,43 (с наивных 2.0), попутно задавив овощи в пол. До честной 1.0 я дожал пропорции руками, фактически перестав слушать harmony-оценку. Зеленая фигура на картинке ниже — это финал, где жир ≈ умами ≈ соль держат базу, а сладость с кислотой подпирают, не давая оливкам солить в одни ворота. Красная пунктирная — наивная бомба. Желтая пунктирная — то, что хотела модель: чуть ровнее бомбы по балансу, но все еще перекос в жир и соль.

Дегустация
Я пожарил только ту, которую довел до ума по параметрам, и по итогу оливки дают соленый укол, кукуруза и карамелизованный лук ловят его сладостью, лайм в конце освежает, сулугуни отлично сочетается. «Это можно подавать». 9/10.
Победила, что характерно, связка «модель предлагает — человек правит метрику». И да, напомню: именно этот итоговый вариант нейросеть оценила в 13%, а сырную бомбу — в 58%. Вопросы к метрике?
Что я из этого всего понял для себя
Закон Гудхарта живет даже на кухне. Модель оптимизирует ровно то, что ты измеряешь, а не то, что ты имеешь в виду. Прокси «похоже на классику» ≠ «вкусно поесть».
Интерпретируемая метрика бьет черный ящик там, где есть физика процесса. Уравнение баланса ρ из одной строчки сделало для результата больше, чем вся нейросеть. Сеть была всего лишь красивым способом обнаружить проблему.
Лучшая архитектура — человек в петле. Модель генерирует, человек правит функцию награды. Скучно, зато съедобно.
Я потратил время, чтобы пожарить кесадилью с помощью градиентного спуска.
P.S. Если дочитали до сюда, то возможно, вы тоже из тех, кто оптимизирует бутерброды, добро пожаловать в клуб.
morozovamia
Боже, это лучше любого рецепта. Теперь я не смогу спокойно готовить, не представляя у себя в голове вектор соли и умами. Особенно понравилось, как ты победил нейросеть обычной сковородкой вот она, настоящая сила человека.