С чего все началось

Я хотел просто пожарить кесадилью. В холодильнике лежали зеленые оливки (солено-кислые), сулугуни и фарш, а на полке — консервированная кукуруза. И вот стою я над сковородкой и думаю: а оливки с кукурузой вообще сочетаются? А сулугуни не пересолит блюдо вместе с оливками? Сколько чего вообще класть?

В любой другой ситуации я бы загуглил рецепт. Но не тут-то было, я же великий комбинатор оптимизатор, и у меня в голове сразу всплыло: «это же задача оптимизации». Тем же вечером у меня был ноутбук с обученной нейросетью вместо ужина. Рассказываю, как дошел до жизни такой, и как из этого внезапно получился реально вкусный рецепт.

Вкус — это вектор

Базовая идея простая. Любой вкус раскладывается на оси. 

Я взял семь: соль, кислота, сладость, горечь, умами, острота, жир.

Тогда каждый ингредиент — это точка в 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. Если дочитали до сюда, то возможно, вы тоже из тех, кто оптимизирует бутерброды, добро пожаловать в клуб.

Комментарии (2)


  1. morozovamia
    18.06.2026 12:29

    Боже, это лучше любого рецепта. Теперь я не смогу спокойно готовить, не представляя у себя в голове вектор соли и умами. Особенно понравилось, как ты победил нейросеть обычной сковородкой вот она, настоящая сила человека.


  1. ProffesorMax
    18.06.2026 12:29

    я делаю проще, яндекс подскажи рецепт ..... )))))))