
С чего все началось
Я хотел просто пожарить кесадилью. В холодильнике лежали зеленые оливки (солено-кислые), сулугуни и фарш, а на полке — консервированная кукуруза. И вот стою я над сковородкой и думаю: а оливки с кукурузой вообще сочетаются? А сулугуни не пересолит блюдо вместе с оливками? Сколько чего вообще класть?
В любой другой ситуации я бы загуглил рецепт. Но не тут-то было, я же великий комбинатор оптимизатор, и у меня в голове сразу всплыло: «это же задача оптимизации». Тем же вечером у меня был ноутбук с обученной нейросетью вместо ужина. Рассказываю, как дошел до жизни такой, и как из этого внезапно получился реально вкусный рецепт.
Вкус — это вектор
Базовая идея простая. Любой вкус раскладывается на оси.
Я взял семь: соль, кислота, сладость, горечь, умами, острота, жир.
Тогда каждый ингредиент — это точка в 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
Облачная инфраструктура для ваших проектов
Виртуальные машины в Москве, Санкт-Петербурге и Новосибирске с оплатой по потреблению.
Уравнение баланса (главный инсайт)
Проблема зеленых оливок: они бьют по двум осям — соль и горечь. Если бросить их в сыр и завернуть, получится соленое нечто, которое невозможно есть.
Как это лечится физиологически? Сладкое и кислое подавляют восприятие соли и горечи. Тот же принцип объясняет, почему в горький кофе кладут сахар, а в соленую карамель, соответственно, соль.
Отсюда метрика, вокруг которой крутится вся идея:
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. Если дочитали до сюда, то возможно, вы тоже из тех, кто оптимизирует бутерброды, добро пожаловать в клуб.




























