Сказ о том, как нейросеть занялась reward hacking прямо у меня на кухне. numpy.. numpy. python.. numpy. python. reward hacking.. numpy. python. reward hacking. selectel.. numpy. python. reward hacking. selectel. закон Гудхарта.. numpy. python. reward hacking. selectel. закон Гудхарта. кулинария.. numpy. python. reward hacking. selectel. закон Гудхарта. кулинария. Машинное обучение.. numpy. python. reward hacking. selectel. закон Гудхарта. кулинария. Машинное обучение. оптимизация.
Сказ о том, как нейросеть занялась reward hacking прямо у меня на кухне - 1

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

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

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

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

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

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

Тогда каждый ингредиент — это точка в 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
Сказ о том, как нейросеть занялась reward hacking прямо у меня на кухне - 2

Облачная инфраструктура для ваших проектов

Виртуальные машины в Москве, Санкт-Петербурге и Новосибирске с оплатой по потреблению.

Подробнее →

Уравнение баланса (главный инсайт)

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

Как это лечится физиологически? Сладкое и кислое подавляют восприятие соли и горечи. Тот же принцип объясняет, почему в горький кофе кладут сахар, а в соленую карамель, соответственно, соль.

Отсюда метрика, вокруг которой крутится вся идея:

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-оценку. Зеленая фигура на картинке ниже — это финал, где жир ≈ умами ≈ соль держат базу, а сладость с кислотой подпирают, не давая оливкам солить в одни ворота. Красная пунктирная — наивная бомба. Желтая пунктирная — то, что хотела модель: чуть ровнее бомбы по балансу, но все еще перекос в жир и соль.

Сказ о том, как нейросеть занялась reward hacking прямо у меня на кухне - 3

Дегустация

Я пожарил только ту, которую довел до ума по параметрам, и по итогу оливки дают соленый укол, кукуруза и карамелизованный лук ловят его сладостью, лайм в конце освежает, сулугуни отлично сочетается. «Это можно подавать». 9/10.

Победила, что характерно, связка «модель предлагает — человек правит метрику». И да, напомню: именно этот итоговый вариант нейросеть оценила в 13%, а сырную бомбу — в 58%. Вопросы к метрике?

Что я из этого всего понял для себя

Закон Гудхарта живет даже на кухне. Модель оптимизирует ровно то, что ты измеряешь, а не то, что ты имеешь в виду. Прокси «похоже на классику» ≠ «вкусно поесть».

Интерпретируемая метрика бьет черный ящик там, где есть физика процесса. Уравнение баланса ρ из одной строчки сделало для результата больше, чем вся нейросеть. Сеть была всего лишь красивым способом обнаружить проблему.

Лучшая архитектура — человек в петле. Модель генерирует, человек правит функцию награды. Скучно, зато съедобно.

Я потратил время, чтобы пожарить кесадилью с помощью градиентного спуска.

P.S. Если дочитали до сюда, то возможно, вы тоже из тех, кто оптимизирует бутерброды, добро пожаловать в клуб.

Автор: inkedsymon

Источник