У статьи есть 1 часть: Стягивай куда нужно: Activation Steering Tutorial

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

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

TLDR:

В прошлой статье мы разобрали классический стиринг на основе построения вектора по средним разностям контранстных пар. Но среднее имеет проблемы, данные имеют проблемы. В этом туториале разберем проблемы (о, простите, третий раз) и open-source методы их решения. Будет много интуиции и математики. С чем поработаем:

Метод

Идея

Отличие

repeng

PCA на попарных разностях

Ищет ось максимального согласованного разброса между парами

pyreft

Обучаемая low-rank интервенция

Интервенция учится на данных, не строится аналитически.

Будет весело. И грустно. И детально. Погнали!

Краткое напоминание, с чем мы работали.

Activation steering — это inference-time интервенция в активации модели. Не файн-тюнинг, не промпт-инженерия — мы буквально берём вектор активаций в момент forward pass и двигаем его:

\mathbf{h}^{(\ell)} \;\leftarrow\; \mathbf{h}^{(\ell)} + \alpha \cdot \hat{\mathbf{v}}

Вектор \hat{\mathbf{v}} строился методом CAA (Contrastive Activation Addition). Мы брали два набора промптов — позитивный класс (tolerant) и негативный (hate) — снимали активации последнего токена на нужном слое и вычисляли разность средних:

\hat{\mathbf{v}} = \frac{\bar{\mathbf{h}}^+ - \bar{\mathbf{h}}^-}{\|\bar{\mathbf{h}}^+ - \bar{\mathbf{h}}^-\|}

Мы посмотрели на результаты и были таковы. Обратите внимание, что в этой формуле мы нормируем весь вектор в единицу (а не вклад каждой пары).

Вводные определения и инутивный смысл

Прежде чем улучшать что-либо как-либо, надо понять своё "что" и почувствовать все возможные "как". Этот блок вы можете прочитать как до основного контента, так и после или во время, если в голове в какой-то момент возникнет вопросительное "зачем".

Что: проблемы среденего

Так как классический CAA аппелирует средней разностью, вспомним свойства среднего. Классический пример лекций по статистике — ситуация, когда в вашу выборку с зарплатой населения пришел Билл-Гейтс.

# Доход в месяц, $

[2000, 3000, 3394, 2789, 2550, 5829, 2000] # до Билла, среднее 3080.29

[2000, 3000, 3394, 2789, 2550, 5829, 200000] # после Билла, среднее 31366.0

Отсюда среднее, и, стало быть, подход, использующий разность средних, чувствительны к выбросам: один нетипичный промпт сместит вектор-центроиду и он может съехать в пространстве. В силу богатства живой лексики или наоборот — низкой вариативности лексики, что часто проблема синтетических данных — нетипичных примеров может быть много. Отсюда, steering сложнее — в прошлом туториале, как вы помните, мы тоже не дошли до идеала. Отсюда, подход CAA и улучшали.

Что: шум разметки

В нашей задаче мы аппелируем парами, отсюда шум возникает (или не возникает) на уровне пар. В общем смысле мы знаем, что все пары по идее про "+" и "-", но не исключаем, что у нас есть пары "+" и "\pm", "\pm" и "-", а ещё у нас может быть спутана разметка "-" и "+". Вот эти непонятки хотелось бы убрать, как и чувствительность к выбросам.

Всё ещё принцип — мусор на входе — мусор на выходе. Хотя мы и поставили задачу свести шум к минимуму, мы всё ещё ограничены требованием того, чтобы этого шума было очень мало. Отсюда при нерабочести стиринга — база, всё же, перепроверить датасет.

Откуда убираем проблему

Разности пар всегда образуют матрицу (назовём её D, D \in R^{n, d_{model}}). Среднее по всем векторам мы раньше назвали направлением стринга. Вопрос — как отыскать направление стабильнее, в условиях выбросов и шума?

Геометрические ответы

Наши данные — это облако векторов в d_\text{model}-мерном пространстве. Каждый вектор \boldsymbol{\delta}_i = \mathbf{h}^+_i - \mathbf{h}^-_i смотрит примерно в сторону концепта, но с шумом (и может быть со спутанным знаком).

Что делает среднее в этой ситуации? Складывает точки из обоих сгустков и делит на n. Если перепутанных пар примерно поровну с правильными — сгустки взаимно гасят друг друга, и среднее уезжает к нулю. Что не меняется в ситуации разных по знаку расстояний? Дисперсия. И мы можем её задействовать, используя PCA. Смотрим на формулу:


\mathbf{v}_1 = \underset{|\mathbf{v}|=1}{\arg\max} \sum_i (\boldsymbol{\delta}_i \cdot \mathbf{v})^2

Это сумма квадратов проекций. Точка на +3 вдоль оси и точка на -3 вдоль той же оси вносят в эту сумму одинаковый вклад — 9 и 9. Знак проекции для формулы не имеет значения, важен только модуль. Значит неважно, сколько пар перепутаны по знаку — пока все \boldsymbol{\delta}_i (перепутанные и нет) лежат вдоль одной и той же оси, 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>

Обозначаются шаблоны как T_f^+ и T_f^-. Это уже знакомая нам пара и дальше мы останемся со случаем функций.

У функции есть естественная бинарная пара — один и тот же инструктаж, два режима (исполнять/не исполнять), что прямо ложится на (positive, negative).

У концепта пары другие: один шаблон T_c применяется к разным стимулам, варьирующимся по интенсивности концепта, а сами пары для PCA и поиска вектора — это случайные пары внутри одного датасета, \{A_c(i) - A_c(j)\}, безо всякой роли "плюс"/"минус" у i и j. PCA здесь ищет ось максимального разброса, не полагаясь на то, какой элемент пары "правильный". Если понадобится именно концептная постановка — смотреть придётся в код оригинальной статьи: github.com/andyzoujm/representation-engineering.

Шаг 2 — снятие активаций. Для каждого стимула/функции снимают представление конкретной токен-позиции — по умолчанию последний токен шаблона — на каждом интересующем слое. Типичный размер датасета — от 5 до 128 пар.

Шаг 3 — построение линейной модели. Для функции f пары стимулов дают активации на экспериментальном шаблоне T_f^+ и референсном T_f^-. Даже при известной роли каждого элемента пары, авторы всё равно рандомизируют знак множителем (-1)^i — оставаясь верными unsupervised-постановке — и нормализуют каждую разность до единичной длины:

\boldsymbol{\delta}_i = \text{normalize}\Bigl((-1)^i\bigl(\mathbf{h}(T_f^+(q_i,a_i)) - \mathbf{h}(T_f^-(q_i,a_i))\bigr)\Bigr)

Затем находят первую главную компоненту набора \{\boldsymbol{\delta}_i\}:

\mathbf{v} = \text{PC}_1\bigl(\{\boldsymbol{\delta}_i\}\bigr) = \underset{\|\mathbf{v}\|=1}{\arg\max}\sum_i (\boldsymbol{\delta}_i \cdot \mathbf{v})^2

Знак \mathbf{v} формула не определяет (собственный вектор задан с точностью до знака) — его находят отдельно, постфактум, по небольшому размеченному подмножеству: если проекции "+"-примеров оказались ниже, чем "-"-примеров, \mathbf{v} просто умножают на -1.

Сноска — детали оригинала.

Статья предлагает три опции:

  1. reading vector, добавленный линейно (вектор после LAT) — самый простой и наименее точный вариант, потому что вектор не зависит от конкретного инпута;

  2. contrast vector — то же самое, но пересчитанное заново - разность на инференсе, для текущего конкретного инпута, без какой-либо PCA-агрегации;

  3. LoRRA — низкоранговые адаптеры, дообученные так, чтобы воспроизводить эффект contrast vector без пересчёта на инференсе; и три способа скомбинировать вектор с активацией — линейная добавка R \pm v, piecewise (добавка с учётом знака проекции) и проекция (обнуление направления вместо усиления). Если хотите расширить арсенал методов от стиринга (первая-частично вторая ситуация) — снова призываю рассмотреть оригинальную работу.

Мы вернемся обратно и рассмотрим прежде repeng-стиринг. Но нам ещё полезно подчеркнуть, что в оригинале предлагают разные способы добавить вектор:

1. Linear Combination — то, что мы использовали везде до сих пор (CAA, repeng):

R' = R \pm v

2. Piece-wise Operation — условный эффект, зависящий от знака:

R' = R + \text{sign}(R^\top v)\,v

Здесь R^\top v — проекция текущей активации на направление v (то самое “чтение” через reading vector). Если проекция уже положительна (модель и так немного “думает” в сторону концепта) — добавляем +v; если отрицательна — тоже добавляем, но с плюсом относительно знака самой активации, то есть эффект усиливает то, что уже есть.

3. Projection — не добавление, а вычитание компоненты вдоль v:

R' = R - \frac{R^\top v}{\|v\|^2}v

Это ортогональная проекция R на гиперплоскость, перпендикулярную v — буквально обнуление направления концепта в активации, а не сдвиг в его сторону. Используется для удаления — например, debiasing.

Они в библиотеке не реализованы, но "хозяйке на заметку", как говорится.

Адаптация

repeng — переупаковка LAT-бейзлайна в pip-библиотеку: тот же workflow "пары → PCA → вектор → hook", но реализация отличается от статьи в четырёх местах. Как и в каких очень подробно вы можете посмотреть в ноутбуках к статье. Мы же здесь пройдемся по списку.

1. Знак пары зафиксирован.

В отличие от LAT (случайный порядок, без меток), \boldsymbol{\delta}_i = 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 идут сырые \{\boldsymbol{\delta}_i\}, а центрирует их сам sklearn внутри .fit() — вычитает среднее по всему набору. Поскольку знак пар в repeng консистентен (всегда positive − negative), это среднее близко к CAA-направлению. Первая компонента здесь — ось, вдоль которой дистанции между positive и negative отклоняются друг от друга сильнее всего.

  • pca_center. В PCA идут уже центрированные попарно данные. Для пары i:

    \text{center}_i = \frac{\mathbf{h}^+_i + \mathbf{h}^-_i}{2}, откуда

    \mathbf{h}^+_i - \text{center}_i = \frac{\boldsymbol{\delta}_i}{2}, \qquad \mathbf{h}^-_i - \text{center}_i = -\frac{\boldsymbol{\delta}_i}{2}

    Сумма этих двух строк — 0 для любого i, при любом \boldsymbol{\delta}_i: каждая пара обнуляется алгебраически, ещё до усреднения. Значит и среднее по всему набору \text{mean}(\text{train}) = 0

    Центрирование обычно удаляет общую компоненту, разделяемую всеми точками — то, в чём согласны почти все пары, — и оставляет PCA судить только об индивидуальных отклонениях. Здесь эту общую компоненту убрала уже сама конструкция данных, до всякого PCA. Вычитать в sklearn.fit() больше нечего — центрирование превращается в no-op (вычитание нуля), и задача фактически становится нецентрированным PCA: первая компонента максимизирует не дисперсию вокруг среднего, а сумму \sum\|\boldsymbol{\delta}_i\|^2 напрямую, то есть просто норму проекций.

    Отсюда и нюанс: без центрирования компонента максимальной дисперсии перестаёт отличать "разброс вокруг типичного значения" от "просто большая величина у одной точки". Одна пара с аномально большой \|\boldsymbol{\delta}_i\| (например, Билл Гейтс против человека Без Определенного Места Жительства) вносит в сумму квадратичный, ничем не ограниченный вклад — и может утащить направление на себя, сколько бы остальных пар ни указывало в сторону настоящего концепта. Поэтому 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": стиринг (добавка) по умолчанию осуществляется на все векторы. Как это хэндлить — в коде. Если запустить эксперимент, то у нас всё получится и мы сместим модель.

Результат эксперимента 1.
Результат эксперимента 1.

Что мы решили и чего не решили.

Целевая функция PCA квадратична по \boldsymbol{\delta}_i: вклад одной пары растёт как \|\boldsymbol{\delta}_i\|^2. У среднего вклад пары линеен и жёстко ограничен — \boldsymbol{\delta}_i / n. Значит для одного экстремального выброса всё ровно наоборот тому, что можно было бы ожидать: у среднего влияние выброса растёт линейно с его величиной, у PCA — квадратично. Один достаточно длинный \boldsymbol{\delta}_i может определить top eigenvector практически единолично, "передавив" сумму вкладов всех остальных пар — Билл Гейтс не просто сдвинет среднее, он ещё и утащит за собой главную ось.

PCA может быть полезнее среднего — но в рамках (приличия) ограничений метода: например, если каждый \boldsymbol{\delta}_i заранее нормализован до единичной длины (тогда квадратичный член не может взорваться от одной длинной пары).

Самое важное: В 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 учит интервенцию вида:

\mathbf{h} \;\leftarrow\; \mathbf{h} + \mathbf{R}^\top \bigl(\mathbf{W}\mathbf{h} + \mathbf{b} \;-\; \mathbf{R}\mathbf{h}\bigr)

Разберём по частям — не страшнее LAT:

  • \mathbf{R} \in \mathbb{R}^{r \times d}low-rank проектор (r \ll d). Строки — ортонормальный базис маленького r-мерного подпространства внутри пространства активаций.

  • \mathbf{R}\mathbf{h} \in \mathbb{R}^r — проекция \mathbf{h} на это подпространство: «координаты \mathbf{h} внутри него».

  • \mathbf{W}\mathbf{h} + \mathbf{b} \in \mathbb{R}^rжелаемые координаты в том же подпространстве.

  • (\mathbf{W}\mathbf{h} + \mathbf{b} - \mathbf{R}\mathbf{h}) — ошибка между текущей проекцией и желаемой.

  • \mathbf{R}^\top(\ldots) — «поднимаем» поправку обратно в d-мерное пространство и прибавляем к \mathbf{h}.

Неформально: мы сдвигаем \mathbf{h} только внутри маленького r-мерного «коридора», оставляя остальные d-r измерений нетронутыми.

Математика DiReFT

DiReFT убирает из LoReFT пару вещей.

\Phi_{\text{DiReFT}}(\mathbf{h}) = \mathbf{h} + \mathbf{W}_2^\top \bigl(\mathbf{W}_1\mathbf{h} + \mathbf{b}\bigr)

Что/зачем:

  1. Разностная операция. В LoReFT поправка — это разница между желаемой проекцией (\mathbf{W}\mathbf{h}+\mathbf{b}) и текущей проекцией (\mathbf{R}\mathbf{h}): интервенция знает, где \mathbf{h} уже находится в подпространстве, и двигает его именно на недостающую разницу.

  2. Ортогональность. \mathbf{R} в LoReFT — матрица с ортонормированными строками (это гарантирует, что подпространство ведёт себя как "чистый" r-мерный срез без искажений метрики). В DiReFT \mathbf{W}_1, \mathbf{W}_2 — просто две независимые low-rank матрицы, без такого ограничения.

Неформально: Уравнение DiReFT структурно совпадает с LoRA. Разница с обычной LoRA только в том, куда прикладывается адаптер — не к весам слоя, а прямо к вектору активации.
Выгода такая — для DiReFT — меньше ограничений — быстрее обучение (не нужно поддерживать ортогональность \mathbf{R} на каждом шаге, не нужно вычислять \mathbf{R}\mathbf{h} отдельно). Но статья прямо говорит, что это ablation, а не улучшение: DiReFT "trades some performance for increased efficiency". Число обучаемых параметров у обоих методов одинаковое (2rd+r), так что разница не в размере, а в постановке.

Training objective: что оптимизируем.

Статья рассматривает две постановки параллельно. Модель с ReFT-интервенцией \Phi и обучаемыми параметрами \varphi обозначается p_\Phi(\cdot).

  • Генерация (decoder-only / encoder-decoder LM): дан промпт x=(x_1,\ldots,x_n), нужно предсказать$y=(y_1,\ldots,y_m) — обычная кросс-энтропия с teacher forcing по всем позициям выхода, тот же loss, что и при обучении самой LM, только градиент течёт в \varphi (\mathbf{R}, \mathbf{W}, \mathbf{b}) при замороженной базовой модели.

  • Классификация (encoder-only): голова H_\theta(\cdot) поверх представления CLS-токена финального слоя, минимизируется кросс-энтропия целевого класса y при входе x.

В обеих постановках интервенция \Phi встроена в forward pass на конкретных позициях/слоях и обучается обычным градиентным спуском.

Почему это прикольно:

  1. Не меняем веса модели — только параметры интервенции (\mathbf{R}, \mathbf{W}, \mathbf{b})

  2. Обучаемых параметров мало: 2rd + r штук;

  3. Работает там, где CAA не работает: если концепт нелинейный или зашумлённый, обученная интервенция найдёт его лучше mean difference

Почему это не идеально:

В CAA и repeng есть явный дискретный шаг: снять активации \mathbf{h}^+ и \mathbf{h}^-, вычислить разность \boldsymbol{\delta}_i, найти направление (среднее или PC1). Направление — это объект, который можно достать и посмотреть.

В LoReFT такого шага нет. Обучающие данные — это пары текстов, не активаций. В ноутбуке подробно описано, что как считается внутри. Но самое важное тут: "контрастность" здесь не мат. объект (разность векторов), а свойство обучающих данных — то, что x и y систематически различаются по нужному признаку (hate vs. tolerant), заставляет градиент раз за разом подталкивать \mathbf{R}, \mathbf{W}, \mathbf{b} в одну и ту же сторону. "Направление" в LoReFT — это поведение обученного модуля: чему он научился, размазано по трём матрицам и восстанавливается только эмпирически — прогоняя разные \mathbf{h} через \Phi_{\text{LoReFT}}$и глядя, куда он их сдвигает, а не читая единый вектор из весов.

Но это практическо-теоретический нюанс с точки зрения анализа направлений. Если задача — сдвинуть модель — метод работает отлично.

Результат эксперимента 2.
Результат эксперимента 2.

Итог

Ух, если вы читаете эти строки — спасибо. Мы прошли огромный путь. Сводная табличка для понимания:

CAA

repeng

pyreft

Как найден вектор

mean(pos) − mean(neg)

PCA на \boldsymbol{\delta}_i

gradient descent

Знак каждой пары нужен заранее

да

нет*

нет

Чувствителен к выбросам по норме

линейно

квадратично**

Охват слоёв

1

настраивается

настраивается

Нелинейный концепт

нет

нет

да

Нужно обучение

нет

нет

да

Вектор можно достать и посмотреть

да

да

нет***

  • * PCA работает на квадратах проекций — знак не важен. repeng фиксирует знак через конструкцию DatasetEntry, но оригинальный LAT из статьи этого не делает и можно пойти к нему.

  • ** Без нормализации \boldsymbol{\delta}_i до PCA — вклад выброса растёт квадратично. Исходный LAT нормализует; repeng нет.

  • *** "Направление" LoReFT размазано по \mathbf{R}, \mathbf{W}, \mathbf{b} и восстанавливается только прогоном разных \mathbf{h} через \Phi.

И буллеты практических штук, чтобы много текста не хрнаить в голове:

  1. Если датасет чистый и большой — CAA работает, и не нужно ничего сложнее.

  2. Если знак пар ненадёжен (разметка шумная, минимальные пары) — LAT/(но не repeng в чистом виде) убирает проблему знака, но не проблему выбросов по норме; нужна нормализация \boldsymbol{\delta}_i.

  3. Если концепт нелинейный или данных мало — pyreft. Но нужна модель достаточного размера и достаточно данных, чтобы не получить петлю (бред в генерации).

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

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

Ссылки:

До новых встреч!