У статьи есть 1 часть: Стягивай куда нужно: Activation Steering Tutorial
Привет, друзья! В первой части мы разобрали базовую идею steering-а и реализовали её тремя способами: через сырые PyTorch hooks, через nnsight и через pyvene. Сработала ли наша база? Да — мы это видели. Нормально ли? Нет — и это мы тоже видели: вектор оказался инвертирован, эффект был умеренным, а на некоторых промптах не было никакого. Что с этим делать и как это сделать на питоне — и есть тема второй части.

Меня всё ещё зовут Сабрина, и примеры из ноутбука всё ещё не выражают мою личную позицию.

TLDR:
В прошлой статье мы разобрали классический стиринг на основе построения вектора по средним разностям контранстных пар. Но среднее имеет проблемы, данные имеют проблемы. В этом туториале разберем проблемы (о, простите, третий раз) и open-source методы их решения. Будет много интуиции и математики. С чем поработаем:
Метод | Идея | Отличие |
| PCA на попарных разностях | Ищет ось максимального согласованного разброса между парами |
| Обучаемая low-rank интервенция | Интервенция учится на данных, не строится аналитически. |
Будет весело. И грустно. И детально. Погнали!
Краткое напоминание, с чем мы работали.
Activation steering — это inference-time интервенция в активации модели. Не файн-тюнинг, не промпт-инженерия — мы буквально берём вектор активаций в момент forward pass и двигаем его:
Вектор строился методом CAA (Contrastive Activation Addition). Мы брали два набора промптов — позитивный класс (tolerant) и негативный (hate) — снимали активации последнего токена на нужном слое и вычисляли разность средних:
Мы посмотрели на результаты и были таковы. Обратите внимание, что в этой формуле мы нормируем весь вектор в единицу (а не вклад каждой пары).
Вводные определения и инутивный смысл
Прежде чем улучшать что-либо как-либо, надо понять своё "что" и почувствовать все возможные "как". Этот блок вы можете прочитать как до основного контента, так и после или во время, если в голове в какой-то момент возникнет вопросительное "зачем".
Что: проблемы среденего
Так как классический CAA аппелирует средней разностью, вспомним свойства среднего. Классический пример лекций по статистике — ситуация, когда в вашу выборку с зарплатой населения пришел Билл-Гейтс.
# Доход в месяц, $ [2000, 3000, 3394, 2789, 2550, 5829, 2000] # до Билла, среднее 3080.29 [2000, 3000, 3394, 2789, 2550, 5829, 200000] # после Билла, среднее 31366.0
Отсюда среднее, и, стало быть, подход, использующий разность средних, чувствительны к выбросам: один нетипичный промпт сместит вектор-центроиду и он может съехать в пространстве. В силу богатства живой лексики или наоборот — низкой вариативности лексики, что часто проблема синтетических данных — нетипичных примеров может быть много. Отсюда, steering сложнее — в прошлом туториале, как вы помните, мы тоже не дошли до идеала. Отсюда, подход CAA и улучшали.
Что: шум разметки
В нашей задаче мы аппелируем парами, отсюда шум возникает (или не возникает) на уровне пар. В общем смысле мы знаем, что все пары по идее про "+" и "-", но не исключаем, что у нас есть пары "+" и "", "
" и "-", а ещё у нас может быть спутана разметка "-" и "+". Вот эти непонятки хотелось бы убрать, как и чувствительность к выбросам.
Всё ещё принцип — мусор на входе — мусор на выходе. Хотя мы и поставили задачу свести шум к минимуму, мы всё ещё ограничены требованием того, чтобы этого шума было очень мало. Отсюда при нерабочести стиринга — база, всё же, перепроверить датасет.
Откуда убираем проблему
Разности пар всегда образуют матрицу (назовём её ). Среднее по всем векторам мы раньше назвали направлением стринга. Вопрос — как отыскать направление стабильнее, в условиях выбросов и шума?
Геометрические ответы
Наши данные — это облако векторов в -мерном пространстве. Каждый вектор
смотрит примерно в сторону концепта, но с шумом (и может быть со спутанным знаком).
Что делает среднее в этой ситуации? Складывает точки из обоих сгустков и делит на n. Если перепутанных пар примерно поровну с правильными — сгустки взаимно гасят друг друга, и среднее уезжает к нулю. Что не меняется в ситуации разных по знаку расстояний? Дисперсия. И мы можем её задействовать, используя PCA. Смотрим на формулу:
Это сумма квадратов проекций. Точка на +3 вдоль оси и точка на -3 вдоль той же оси вносят в эту сумму одинаковый вклад — 9 и 9. Знак проекции для формулы не имеет значения, важен только модуль. Значит неважно, сколько пар перепутаны по знаку — пока все (перепутанные и нет) лежат вдоль одной и той же оси, PCA эту ось найдёт. Задача "кто тут '+', а кто '-'" снимается с этапа поиска направления и переносится на следующий шаг — определение знака уже найденной оси, обычно по небольшому размеченному подмножеству, уже после того как ось найдена.
Именно эта постановка — PCA на разностях, без требования заранее знать, какой конец пары правильный — лежит в основе Linear Activation Tomography из статьи "Representation Engineering: A Top-Down Approach to AI Transparency". Для этой постановки нет фреймворка, но есть исходный код, а ещё есть интересная производная этой статьи, которую мы сейчас потрогаем (и огорчимся, но потом мы потрогаем фреймворк на LoR-e и, надеюсь, вы порадуетесь к концу).
RepE (оригинал) vs repeng
Фреймворк repeng— это производная от "Representation Engineering: A Top-Down Approach to AI Transparency". Прозводная, которая неплохо себя показала. Она берет метод из оригинальной статьи для извлечения направления — LAT (Linear Artificial Tomography). Название красивое и может напугать, но просто про аккуратное конструирование данных и строится в три шага.
Посмотрим на оригинальный алгоритм:
Шаг 1 — дизайн стимулов. Авторы разделяют два типа понятий в первом шаге — концет (статичный объект) и функция (динамичное поведение). Нас будет интересовать далее только второе понятие, но я хочу отметить этот шаг.
Для концептов (например, "правдивость") — цель вытащить декларативное знание: модели показывают стимул и спрашивают про концепт напрямую:
Пример дизайна концепта
> Consider the amount of <concept> in the following: <stimulus>. The amount of <concept> is ___
По постановке, модель верне какую-то "меру" (не в мат. смысле) наличия концепта.
Для функций (например, "честность", то есть поведение, а не статичное знание) — цель вытащить процедурное знание, поэтому нужны два шаблона: экспериментальный (просит функцию исполнить) и референсный (не просит):
Пример дизайна функции
> USER: <instruction> <experimental/reference prompt>
> ASSISTANT: <output>
Обозначаются шаблоны как и
. Это уже знакомая нам пара и дальше мы останемся со случаем функций.
У функции есть естественная бинарная пара — один и тот же инструктаж, два режима (исполнять/не исполнять), что прямо ложится на (positive, negative).
У концепта пары другие: один шаблон применяется к разным стимулам, варьирующимся по интенсивности концепта, а сами пары для PCA и поиска вектора — это случайные пары внутри одного датасета,
, безо всякой роли "плюс"/"минус" у i и j. PCA здесь ищет ось максимального разброса, не полагаясь на то, какой элемент пары "правильный". Если понадобится именно концептная постановка — смотреть придётся в код оригинальной статьи: github.com/andyzoujm/representation-engineering.
Шаг 2 — снятие активаций. Для каждого стимула/функции снимают представление конкретной токен-позиции — по умолчанию последний токен шаблона — на каждом интересующем слое. Типичный размер датасета — от 5 до 128 пар.
Шаг 3 — построение линейной модели. Для функции f пары стимулов дают активации на экспериментальном шаблоне и референсном
. Даже при известной роли каждого элемента пары, авторы всё равно рандомизируют знак множителем
— оставаясь верными unsupervised-постановке — и нормализуют каждую разность до единичной длины:
Затем находят первую главную компоненту набора :
Знак формула не определяет (собственный вектор задан с точностью до знака) — его находят отдельно, постфактум, по небольшому размеченному подмножеству: если проекции "+"-примеров оказались ниже, чем "-"-примеров,
просто умножают на -1.
Сноска — детали оригинала.
Статья предлагает три опции:
reading vector, добавленный линейно (вектор после LAT) — самый простой и наименее точный вариант, потому что вектор не зависит от конкретного инпута;
contrast vector — то же самое, но пересчитанное заново - разность на инференсе, для текущего конкретного инпута, без какой-либо PCA-агрегации;
LoRRA — низкоранговые адаптеры, дообученные так, чтобы воспроизводить эффект contrast vector без пересчёта на инференсе; и три способа скомбинировать вектор с активацией — линейная добавка
, piecewise (добавка с учётом знака проекции) и проекция (обнуление направления вместо усиления). Если хотите расширить арсенал методов от стиринга (первая-частично вторая ситуация) — снова призываю рассмотреть оригинальную работу.
Мы вернемся обратно и рассмотрим прежде repeng-стиринг. Но нам ещё полезно подчеркнуть, что в оригинале предлагают разные способы добавить вектор:
1. Linear Combination — то, что мы использовали везде до сих пор (CAA, repeng):
2. Piece-wise Operation — условный эффект, зависящий от знака:
Здесь — проекция текущей активации на направление
(то самое “чтение” через reading vector). Если проекция уже положительна (модель и так немного “думает” в сторону концепта) — добавляем
; если отрицательна — тоже добавляем, но с плюсом относительно знака самой активации, то есть эффект усиливает то, что уже есть.
3. Projection — не добавление, а вычитание компоненты вдоль :
Это ортогональная проекция на гиперплоскость, перпендикулярную
— буквально обнуление направления концепта в активации, а не сдвиг в его сторону. Используется для удаления — например, debiasing.
Они в библиотеке не реализованы, но "хозяйке на заметку", как говорится.
Адаптация
repeng — переупаковка LAT-бейзлайна в pip-библиотеку: тот же workflow "пары → PCA → вектор → hook", но реализация отличается от статьи в четырёх местах. Как и в каких очень подробно вы можете посмотреть в ноутбуках к статье. Мы же здесь пройдемся по списку.
1. Знак пары зафиксирован.
В отличие от LAT (случайный порядок, без меток), = positive − negative консистентен по всему датасету.
repeng supervised по конструкции (DatasetEntry), просто без явных численных меток — то самое "необязательно размеченные" из Шага 3 тут уже неверно.
2. Нет нормализации. У оригинала (см Appendix C.1) : normalize(H(si) − H(si+1))). В исходном коде либы (нас будет интересовать файл extract.py) такой строчки нет — сырые разности идут в PCA(n_components=1).fit(train) как есть. repeng отсюда уязвим к выбросам по норме.
3. Два метода и нюанс адаптациии 1: поиск направления и среднее
repeng предлагает два способа получить направление.
pca_diff(default). В PCA идут сырые, а центрирует их сам sklearn внутри
.fit()— вычитает среднее по всему набору. Поскольку знак пар вrepengконсистентен (всегда positive − negative), это среднее близко к CAA-направлению. Первая компонента здесь — ось, вдоль которой дистанции между positive и negative отклоняются друг от друга сильнее всего.pca_center. В PCA идут уже центрированные попарно данные. Для пары i:, откуда
Сумма этих двух строк — 0 для любого i, при любом
: каждая пара обнуляется алгебраически, ещё до усреднения. Значит и среднее по всему набору
Центрирование обычно удаляет общую компоненту, разделяемую всеми точками — то, в чём согласны почти все пары, — и оставляет PCA судить только об индивидуальных отклонениях. Здесь эту общую компоненту убрала уже сама конструкция данных, до всякого PCA. Вычитать вsklearn.fit()больше нечего — центрирование превращается в no-op (вычитание нуля), и задача фактически становится нецентрированным PCA: первая компонента максимизирует не дисперсию вокруг среднего, а суммунапрямую, то есть просто норму проекций.
Отсюда и нюанс: без центрирования компонента максимальной дисперсии перестаёт отличать "разброс вокруг типичного значения" от "просто большая величина у одной точки". Одна пара с аномально большой
(например, Билл Гейтс против человека Без Определенного Места Жительства) вносит в сумму квадратичный, ничем не ограниченный вклад — и может утащить направление на себя, сколько бы остальных пар ни указывало в сторону настоящего концепта. Поэтому
pca_centerне устойчив к выбросам. Главный движок процесса —ControlVector.train(...)— без явногоmethod=используетpca_diff— центрированный, но ненормализованный PCA.
4. Знак направления:
positive_smaller_mean = np.mean([projected_hiddens[i] < projected_hiddens[i+1] for i in range(0, len(inputs)*2, 2)]) positive_larger_mean = np.mean([projected_hiddens[i] > projected_hiddens[i+1] for i in range(0, len(inputs)*2, 2)]) if positive_smaller_mean > positive_larger_mean: directions[layer] *= -1
Для каждой пары сравниваем projected_hiddens[i] (positive) и [i+1] (negative) как числа, но в сумму идёт не разница, а результат сравнения — 0 или 1. Усредняя эти булевы результаты по всем парам, получаем долю пар с "неправильным" и "правильным" порядком; если неправильных больше — флипаем знак.
Почему это хорошо — пары с большой нормой вносят в знак направления такой же вес, что и пары с малой нормой.Если бы вместо голосования по долям здесь считали mean(projected_hiddens[pos]) vs mean(projected_hiddens[neg]) (то есть сравнивали бы средние величины, а не результаты сравнения).

И если на этом этапе вам стало сложно — мне тоже было! Поэтому в ноутбуке лежит числовой пример. Сходите к нему — он очень помогает все уложить.
Для библиотеки также справедлив "нюанс адаптациии 2": стиринг (добавка) по умолчанию осуществляется на все векторы. Как это хэндлить — в коде. Если запустить эксперимент, то у нас всё получится и мы сместим модель.

Что мы решили и чего не решили.
Целевая функция PCA квадратична по : вклад одной пары растёт как
. У среднего вклад пары линеен и жёстко ограничен —
. Значит для одного экстремального выброса всё ровно наоборот тому, что можно было бы ожидать: у среднего влияние выброса растёт линейно с его величиной, у PCA — квадратично. Один достаточно длинный
может определить top eigenvector практически единолично, "передавив" сумму вкладов всех остальных пар — Билл Гейтс не просто сдвинет среднее, он ещё и утащит за собой главную ось.
PCA может быть полезнее среднего — но в рамках (приличия) ограничений метода: например, если каждый заранее нормализован до единичной длины (тогда квадратичный член не может взорваться от одной длинной пары).
Самое важное: В repeng этой защиты нет ни в pca_diff, ни в pca_center. Так что проблема с выбросами (шумом) здесь актуальна в чистом виде. Библиотека у нас — про другой способ поиска вектора. И если хочется решить проблему, используя PCA, то поможет только теория выше. Train repeng-а же— это нахождение векторов описанным методом и коррекция знака.
Да ну вашу геометрию: погнали обучаться
Второй популярной фреймворк — pyreft (от Representation Fine-Tuning, ReFT). Он реализует вообще другой подход: интервенция обучается на данных, а не конструируется аналитически.
ReFT — это семейство методов. В оригинальной статье ReFT: Representation Finetuning for Language Models описаны два представителя:
LoReFT (Low-rank Linear Subspace ReFT) — интервенция в низкоранговом линейном подпространстве.
DiReFT — история оптимальнее с основной мотивацией — закинуть вмешательство не в веса, а прямо к остаточному потоку модели (в ее представления).
Математика LoReFT
LoReFT учит интервенцию вида:
Разберём по частям — не страшнее LAT:
— low-rank проектор (
). Строки — ортонормальный базис маленького
-мерного подпространства внутри пространства активаций.
— проекция
на это подпространство: «координаты
внутри него».
— желаемые координаты в том же подпространстве.
— ошибка между текущей проекцией и желаемой.
— «поднимаем» поправку обратно в d-мерное пространство и прибавляем к
.
Неформально: мы сдвигаем только внутри маленького
-мерного «коридора», оставляя остальные
измерений нетронутыми.
Математика DiReFT
DiReFT убирает из LoReFT пару вещей.
Что/зачем:
Разностная операция. В LoReFT поправка — это разница между желаемой проекцией (
) и текущей проекцией (
): интервенция знает, где
уже находится в подпространстве, и двигает его именно на недостающую разницу.
Ортогональность.
в LoReFT — матрица с ортонормированными строками (это гарантирует, что подпространство ведёт себя как "чистый" r-мерный срез без искажений метрики). В DiReFT
— просто две независимые low-rank матрицы, без такого ограничения.
Неформально: Уравнение DiReFT структурно совпадает с LoRA. Разница с обычной LoRA только в том, куда прикладывается адаптер — не к весам слоя, а прямо к вектору активации.
Выгода такая — для DiReFT — меньше ограничений — быстрее обучение (не нужно поддерживать ортогональность на каждом шаге, не нужно вычислять
отдельно). Но статья прямо говорит, что это ablation, а не улучшение: DiReFT "trades some performance for increased efficiency". Число обучаемых параметров у обоих методов одинаковое (
), так что разница не в размере, а в постановке.
Training objective: что оптимизируем.
Статья рассматривает две постановки параллельно. Модель с ReFT-интервенцией и обучаемыми параметрами
обозначается
.
Генерация (decoder-only / encoder-decoder LM): дан промпт
, нужно предсказать$
— обычная кросс-энтропия с teacher forcing по всем позициям выхода, тот же loss, что и при обучении самой LM, только градиент течёт в
) при замороженной базовой модели.
Классификация (encoder-only): голова
поверх представления CLS-токена финального слоя, минимизируется кросс-энтропия целевого класса y при входе x.
В обеих постановках интервенция встроена в forward pass на конкретных позициях/слоях и обучается обычным градиентным спуском.
Почему это прикольно:
Не меняем веса модели — только параметры интервенции (
)
Обучаемых параметров мало:
штук;
Работает там, где CAA не работает: если концепт нелинейный или зашумлённый, обученная интервенция найдёт его лучше mean difference
Почему это не идеально:
В CAA и repeng есть явный дискретный шаг: снять активации и
, вычислить разность
, найти направление (среднее или PC1). Направление — это объект, который можно достать и посмотреть.
В LoReFT такого шага нет. Обучающие данные — это пары текстов, не активаций. В ноутбуке подробно описано, что как считается внутри. Но самое важное тут: "контрастность" здесь не мат. объект (разность векторов), а свойство обучающих данных — то, что x и y систематически различаются по нужному признаку (hate vs. tolerant), заставляет градиент раз за разом подталкивать в одну и ту же сторону. "Направление" в LoReFT — это поведение обученного модуля: чему он научился, размазано по трём матрицам и восстанавливается только эмпирически — прогоняя разные
через
$и глядя, куда он их сдвигает, а не читая единый вектор из весов.
Но это практическо-теоретический нюанс с точки зрения анализа направлений. Если задача — сдвинуть модель — метод работает отлично.

Итог
Ух, если вы читаете эти строки — спасибо. Мы прошли огромный путь. Сводная табличка для понимания:
CAA | repeng | pyreft | |
Как найден вектор | mean(pos) − mean(neg) | PCA на | gradient descent |
Знак каждой пары нужен заранее | да | нет* | нет |
Чувствителен к выбросам по норме | линейно | квадратично** | — |
Охват слоёв | 1 | настраивается | настраивается |
Нелинейный концепт | нет | нет | да |
Нужно обучение | нет | нет | да |
Вектор можно достать и посмотреть | да | да | нет*** |
* PCA работает на квадратах проекций — знак не важен. repeng фиксирует знак через конструкцию
DatasetEntry, но оригинальный LAT из статьи этого не делает и можно пойти к нему.** Без нормализации
до PCA — вклад выброса растёт квадратично. Исходный LAT нормализует;
repengнет.*** "Направление" LoReFT размазано по
и восстанавливается только прогоном разных
через
.
И буллеты практических штук, чтобы много текста не хрнаить в голове:
Если датасет чистый и большой — CAA работает, и не нужно ничего сложнее.
Если знак пар ненадёжен (разметка шумная, минимальные пары) — LAT/(но не repeng в чистом виде) убирает проблему знака, но не проблему выбросов по норме; нужна нормализация
.
Если концепт нелинейный или данных мало — pyreft. Но нужна модель достаточного размера и достаточно данных, чтобы не получить петлю (бред в генерации).

Если вам понравилось, присоединяйтесь к Just Data Blog— я стану охватываемым каналом и буду радоваться от того, что получается приносит в мир больше прикольных штук.
Если вам понравилось, присоединяйтесь к Just Data Blog— я стану охватываемым каналом и буду радоваться от того, что получается приносит в мир больше прикольных штук.
Ссылки:
Мой канал: Just Data Blog
До новых встреч!
