SynthID-Text — генеративная схема водяного знакастатистическая метка, встраиваемая в текст при генерации и обнаруживаемая по корреляции между случайным зерном и выбранными токенами. Она строится на новом алгоритме сэмплирования Tournament samplingпроцедура выбора токена, в которой кандидаты попарно соревнуются в турнире, а победитель определяется секретным ключом. Схема может быть настроена как non-distortionaryсохраняющая распределение выходных токенов языковой модели в среднем по случайному зерну или distortionaryповышающая обнаруживаемость за счёт качества текста. Вопрос, который разбираем: почему неискажающий характер схемы не защищает от того, что сама процедура выбора токенов начинает влиять на поведение модели — вплоть до действий ИИ-агента.
Что означает non-distortionary и где проходит граница
Non-distortionary определено на нескольких уровнях строгости. Слабейший — single-token non-distortionв среднем по случайному зерну распределение выходного токена равно исходному распределению языковой модели. Более сильные версии распространяют это определение на одну или несколько последовательностей текста: в среднем вероятность, что схема сгенерирует конкретный текст или последовательность текстов, совпадает с исходной моделью.
Tournament sampling: при двух соперниках схема является single-token non-distortionary. Применение repeated context maskingпропуск наложения метки на контекстах, которые уже встречались ранее делает её non-distortionary для одной или нескольких последовательностей.
Distortionary-конфигурация использует более двух соперников. Это даёт более сильный водяной знак, но искажает распределение на уровне токена.
Выбор уровня non-distortion — компромисс: слабые уровни могут снижать качество и разнообразие текста, сильные — уменьшать обнаруживаемость и увеличивать вычислительную сложность.
Как устроена конфигурация и точки встраивания
Конфигурация водяного знака задаётся через WatermarkingConfig — это TypedDictсловарь с фиксированным набором ключей и типами значений, который проверяется статически. В нём перечислены параметры, определяющие, как именно встраивается метка: ngram_len — длина n-граммацепочки из нескольких подряд идущих токенов, по которой считается хеш контекста; keys — секретные ключи, от которых зависят g-значениячисла 0 или 1, которые схема присваивает токенам-кандидатам и по которым затем отличает помеченный текст от непомеченного; sampling_table_size и sampling_table_seed — размер и зерно таблицы сэмплирования; context_history_size — сколько уже встреченных хешей контекста хранится для распознавания повторов; device — устройство, на котором ведутся вычисления. Готовая конфигурация по умолчанию DEFAULT_WATERMARKING_CONFIG подставляется в процессор логитов целиком, а отдельные её значения могут переопределяться вызывающим кодом. В конструкторе процессора логитов собственные значения по умолчанию есть только у skip_first_ngram_calls = False, apply_top_k = True и num_leaves = 2; ngram_len, keys, context_history_size, temperature, top_k и device задавать обязательно.
Состояние процесса хранится в SynthIDState: ему передают batch_size, ngram_len, context_history_size и device, а он заводит context размера (batch_size, ngram_len - 1) — последние токены, образующие текущий контекст, — и context_history размера (batch_size, context_history_size) — накопленные хеши встреченных контекстов.
Встраивание в пайплайн HuggingFace идёт через примесь SynthIDSparseTopKMixin, наследующую transformers.GenerationMixin. Примесь переопределяет _get_logits_warper — метод, который в HuggingFace собирает список обработчиков логитов, применяемых перед выбором токена. Сначала она проверяет, что temperature задана и лежит в диапазоне от 0.0 до 1.0, иначе выбрасывает ValueError; затем проверяет, что top_k задан и не меньше 1, иначе тоже ValueError. После этого temperature и top_k кладутся в extra_params, и собирается список обработчиков логитов, в который добавляется SynthIDLogitsProcessor с DEFAULT_WATERMARKING_CONFIG и extra_params. Именно этот обработчик и накладывает водяной знак в ходе генерации.
_sample — скопированная из HuggingFace с минимальными изменениями функция multinomial sampling, принимающая logits_warper и возвращающая GenerateNonBeamOutput или torch.LongTensor. Обёрнуты две модели: SynthIDGPT2LMHeadModel (наследует SynthIDSparseTopKMixin и transformers.GPT2LMHeadModel) и SynthIDGemmaForCausalLM (наследует примесь и transformers.GemmaForCausalLM) — с тем же API, что и у Transformers.
Инициализация состояния и проверки формы
_init_state создаёт SynthIDState с переданными batch_size, ngram_len, context_history_size и device. hash_iv формируется в конструкторе как sha256 от байтов keys, приведённый к int по модулю torch.iinfo(torch.int64).max, и используется как начальное значение хеша для всех batch-записей.
_check_input_ids_shape требует, чтобы input_ids имел ровно две размерности (batch_size, input_len), иначе бросает ValueError.
compute_context_repetition_mask принимает (batch_size, input_len) и возвращает маску (batch_size, input_len - (ngram_len - 1)), где 0 — повторённый контекст, 1 — не повторённый. compute_eos_token_mask принимает (batch_size, input_len) и возвращает маску той же формы, где 1 — n-граммы без EOS, 0 — начиная с первого EOS.
При повторном вызове в watermarked_call к state.context добавляется последний input_id и отбрасывается первый столбец.
Tournament sampling пошагово
Ключи и g-значения вычисляются хэшированием контекста, индексов и ключей. Сначала хэшируется контекст из предыдущих токенов от начального вектора, затем к результату добавляются индексы продолжений, и наконец — ключи по измерению глубины, что даёт тензор формы [batch_size, num_indices, depth]. Для полных n-грамм выполняется та же процедура: проверяется форма (batch_size, num_ngrams, ngram_len), хэш инициализируется тем же начальным вектором и последовательно применяется к n-граммам и ключам.
Из ключей n-грамм выводятся g-значения: хэш многократно применяется со сдвигом shift = 64 // num_apply_hash, а затем берётся один бит — (ngram_keys >> 30) % 2. Параметр ngram_len задаёт длину n-граммы: он сохраняется в состоянии, проверяется при обработке полных n-грамм и используется для пропуска первых вызовов, пока num_calls < self.ngram_len.
Как g-значения модифицируют scores
update_scores принимает логиты и g-значения формы (batch_size, vocab_size, depth), переводит логиты в вероятности через torch.softmax(scores, dim=1), а затем для каждого уровня глубины вычисляет g_mass_at_depth как взвешенную по вероятностям сумму g-значений и умножает вероятности на (1 + g_values_at_depth - g_mass_at_depth). Возвращается логарифм обновлённых вероятностей, нефинитные значения заменяются на -1e12.
update_scores_distortionary делает то же, но вместо одного множителя использует два коэффициента:
coeff_not_in_g = (1 - g_mass_at_depth)**(num_leaves - 1)
coeff_in_g = (1 - (1 - g_mass_at_depth)**(num_leaves)) / g_mass_at_depthcoeff_in_g выбирается там, где g_values_at_depth == 1 и probs > 0, coeff_not_in_g — в остальных случаях. В неискажающем режиме каждый токен получает аддитивную поправку к вероятности, в искажающем — вероятности токенов с g=1 и g=0 масштабируются разными коэффициентами, зависящими от num_leaves. В обоих случаях top-k внутри этих функций не изменяется: возвращаются обновлённые логиты для всего словаря, а выбор top-k и подстановка scores_top_k при повторяющемся контексте выполняются в вызывающем коде через torch.where(is_repeated_context, input=scores_top_k, other=updated_scores).
Повторяющийся контекст и EOS
compute_context_repetition_mask для каждого n-1-грамма (окна длины ngram_len - 1) хеширует контекст и проверяет, встречался ли такой хеш ранее в state.context_history; возвращает 1 для неповторяющихся и 0 для повторяющихся. compute_eos_token_mask для каждой последовательности находит первый EOS и обнуляет всё начиная с него, оставляя 1 только до первого EOS.
context_history_size задаёт размер тензора, в котором хранятся уже встреченные хеши контекстов, и передаётся в SynthIDState при инициализации.
Повторное использование контекста обрабатывается в get_gvals: если текущий контекст уже встречался, вместо обновлённых скоров берутся исходные scores_top_k, то есть наложение метки пропускается. В детекторе обе маски перемножаются: combined_mask = context_repetition_mask * eos_token_mask, чтобы исключить повторяющиеся контексты и токены после EOS из подсчёта.
Детекция: mean-скор
mean_score принимает g-значения формы [batch_size, seq_len, watermarking_depth] и маску формы [batch_size, seq_len], где mask — бинарный массив, помечающий используемые g-значения; значения с mask=0 отбрасываются. Сначала вычисляется число неотфильтрованных позиций по токенам, затем сумма по токенам и слоям берётся от произведения g-значений на mask, расширенный по последней оси, и делится на watermarking_depth * num_unmasked, давая скор формы [batch_size].
weighted_mean_score делает то же, но перед агрегацией умножает g-значения на веса по глубине. Если веса не заданы, берутся линейно убывающие от 10 до 1 через jnp.linspace(start=10, stop=1, num=watermarking_depth). Веса нормируются так, чтобы их сумма равнялась watermarking_depth, после чего применяются к g-значениям через jnp.expand_dims(weights, axis=(0, 1)).
Байесовский детектор
LikelihoodModelWatermarked — модель правдоподобия для g-значений (0 или 1), возвращающая P(g_values|watermarked); её параметры beta и delta инициализируются шумом, а l2_loss считает сумму квадратов delta.
_compute_latents вычисляет два латента формы [batch_size, seq_len, watermarking_depth]: p_one_unique_token и p_two_unique_tokens, причём их сумма равна 1. Для этого g-значения размножаются по новой оси, элементы выше диагонали k=-1 обнуляются через jnp.tril для авторегрессионной факторизации, логит считается как сумма delta*x плюс beta, затем p_two_unique_tokens = sigmoid(logits), p_one_unique_token = 1 - p_two_unique_tokens.
В __call__ модели Watermarked возвращается 0.5 * ((g_values + 0.5) * p_two_unique_tokens + p_one_unique_token). LikelihoodModelUnwatermarked возвращает 0.5 * jnp.ones_like(g_values) — каждое g-значение имеет вероятность 0.5.
_compute_posterior берёт логарифмы обоих правдоподобий с ограничением снизу 1e-30, считает log odds как их разность, суммирует относительные сюрпризы по всем позициям токенов и слоям через jnp.einsum('i...->i', log_odds * mask), добавляет prior-член log(prior) - log(1 - prior) и возвращает sigmoid(relative_surprisal).
Обучение детектора
process_outputs_for_training превращает сырые выходы модели в формат детектора: для каждого батча считает eos-маску, при is_pos или is_cv обрезает по pos_truncation_length, иначе по neg_truncation_length, пропускает пустые батчи, паддит до max_length, затем строит combined_mask = context_repetition_mask * eos_token_mask и g-значения, паддя их слева, и возвращает all_masks и all_g_values.
train_best_detector_given_g_values перебирает l2_weights, для каждого создаёт BayesianDetectorModule с watermarking_depth=len(logits_processor.keys), обучает и оставляет детектор с наименьшим min_val_loss. l2_loss возвращает jnp.einsum('ijkl->', self.delta**2) — сумму квадратов delta. xentropy_loss считает кросс-энтропию после клиппинга предсказаний в [1e-5, 1-1e-5]; loss_fn складывает xentropy_loss и взвешенный L2.
coshuffle применяет jax.random.permutation(shuffle_rng, x) к каждому аргументу, чтобы одинаково перемешать g-значения, маску и метку. validate_with_minibatches прогоняет loss_fn_jitted_val по минибатчам и возвращает среднее; при ValidationMetric.TPR_AT_FPR вместо этого используется update_fn_if_fpr_tpr, возвращающий -tpr_at_fpr. Лучший детектор выбирается по минимуму val_loss: best_val_epoch = np.argmin(val_loss) + 1, после чего параметры детектора заменяются параметрами лучшей эпохи.
Метрика tpr_at_fpr и интерпретация score
tpr_at_fpr вычисляет долю положительных примеров, чей score не ниже порога, установленного по отрицательным: берутся positive_scores и negative_scores, порог задаётся как jnp.percentile(negative_scores, 100 - target_fpr * 100), результат — jnp.mean(positive_scores >= fpr_threshold).
detector.score() возвращает per-example score — по одному числу на каждый пример батча, вычисленному как posterior P(watermarked|g_values) через _compute_posterior. Score лежит в диапазоне [0, 1], где 0 означает «не водянистый», а 1 — «водянистый»; порог для решения не фиксирован в коде, его предлагается настраивать под свои нужды. Внутри score() формирует eos_token_mask и context_repetition_mask, перемножает их в combined_mask, вычисляет g-значения и передаёт их в detector_module.score, который возвращает массив формы [batch_size].
Короткие тексты и первые шаги генерации
На коротких текстах и первых шагах генерации детектируемость снижается, потому что для оценки используется меньше n-грамм. В score() маска EOS обрезается, пропуская первые ngram_len - 1 токенов, а маска повторения контекста имеет длину output_len - (ngram_len - 1) — первые ngram_len - 1 позиций не дают вклада. compute_eos_token_mask обнуляет маску начиная с первого EOS-токена, поэтому все n-граммы после EOS исключаются из подсчёта. Итоговая combined_mask перемножает обе маски, так что позиции, отброшенные хотя бы одной из них, не учитываются при вычислении posterior.
Длина текста — один из двух главных факторов детектируемости: длинные тексты содержат больше свидетельств водяного знака, поэтому статистическая уверенность при принятии решения выше. На коротких текстах и первых шагах генерации, где доступно мало n-грамм, свидетельств меньше.
Компромиссы качества и детектируемости
Недисторсионный режим сохраняет качество текста и даёт хорошую детектируемость, но снижает разнообразие между ответами. Дисторсионный режим даёт более высокую детектируемость за счёт некоторой потери качества. В дисторсионном режиме оба метода (distortionary SynthID-Text и Soft Red List) имеют strength-параметр, управляющий компромиссом между детектируемостью и качеством текста, причём у distortionary SynthID-Text этот компромисс более выгодный.
Почему возможна атака sampling drift
Эффект sampling driftвлияние изменений в семплировании не только на формулировки, но и на поведение модели возможен потому, что водяной знак вносится в сам процесс генерации и меняет приоритеты токенов, а не только формулировки: секретный ключ незаметно меняет приоритеты кандидатов — одни токены получают преимущество, другие выбираются реже. Текст при этом выглядит естественно, но в последовательности выбранных токенов появляется скрытый паттерн.
Схема может быть неискажающей по качеству: при конфигурации Tournament sampling с ровно двумя соперниками в каждом матче она является single-token non-distortionary. Предсказуемость для инъекции создаётся тем, что g-значения вычисляются детерминированно из хеша токена, номера слоя и секретного ключа, а ключи для n-грамм считаются хешированием предыдущих токенов, то есть зависят от контекста. При известном или подобранном ключе можно заранее рассчитать, какие токены получат преимущество.
Если LLM используется в составе ИИ-агента, те же изменения в выборе токенов могут повлиять на то, какой инструмент вызовет агент и какие аргументы ему передаст. В тестах маркировка влияла на отдельные вызовы инструментов и приводила к новым ошибкам, хотя иногда исправляла прежние проблемы. Поведение зависело от конкретного секретного ключа: разные ключи по-разному влияли на вероятность выполнения вредоносных инструкций. Сипосова рекомендует разработчикам проводить red team-тесты уже после включения SynthID, а не рассчитывать, что поведение модели останется прежним.
Как сравнивали и что получилось
Стенд и методология
В экспериментах использовались IT-варианты моделей Gemma 2B и 7B, а также Mistral 7B-IT версии v0.2. Для генерации применялось top-k сэмплирование с k=100 для IT-моделей, температуры 0.5, 0.7 и 1.0 — варьирование температуры меняет энтропию модели, что влияет на детектируемость.
Для промптов использовался датасет ELI5 — английские вопросы, требующие развёрнутых многосоставных ответов. Для неискажающего водяного знака тестовый и развивающий наборы ELI5 содержали по 10 000 непересекающихся промптов; для искажающего — 1500 промптов из ELI5 для тестового набора. В качестве негативов использовались два непересекающихся набора человеческих ответов на 10 000 вопросов из ELI5 для развивающего и тестового наборов.
Детектируемость измеряется TPR при фиксированном FPR=1% (TPR@FPR=1%). Сравнивалась детектируемость как функция длины текста для non-distortionary SynthID-Text и Gumbel sampling. Тексты для одного сравнения — длиной 400 токенов, сгенерированы Gemma 7B-IT при трёх температурах. Для distortionary-сравнения — длиной 200 токенов, Gemma 7B-IT, temperature=0.7.
Воспроизведение: ноутбук запускается после старта ядра. Обучение детектора — через train_detector_bayesian.optimize_model с train/test g-значениями, масками и метками. Конфигурация берётся из synthid_mixin.DEFAULT_WATERMARKING_CONFIG, SynthIDLogitsProcessor создаётся с CONFIG, top_k и temperature; далее вычисляются eos_token_mask, context_repetition_mask, combined_mask и g-значения, после чего вызывается detector.score. Тесты входят в исходную установку. Данные human evaluation лежат в data/human_eval.jsonl, промпты загружаются через tfds.load('huggingface:eli5/LFQA_reddit', split='test_eli5') и сопоставляются по q_id.
Живой эксперимент на Gemini
Случайная доля запросов направлялась к модели с водяным знаком, эквивалентное число — к модели без него. Проанализировали примерно 20 миллионов ответов с водяным знаком и без него и вычислили доли положительных и отрицательных отзывов как долю от общего числа полученных отзывов.
| Метрика | Разница | Направление |
|---|---|---|
| Доля положительных отзывов | 0,01% | у модели с водяным знаком выше |
| Доля отрицательных отзывов | 0,02% | у модели с водяным знаком ниже |
Обе разницы признаны статистически незначимыми и в пределах 95% доверительных интервалов.
Дополнительно провели меньший контролируемый тест человеческих предпочтений: сравнивали ответы Gemma 7B-IT с водяным знаком и без него на 3000 вопросах ELI5 по пяти аспектам качества — грамматичность/связность, релевантность, правильность, полезность и общее качество. По всем пяти аспектам значимых различий в предпочтениях оценщиков не нашли.
Оговорки
Авторы прямо указывают, что генеративные водяные знаки не являются полным решением для обнаружения ИИ-текста, а лишь дополняют другие подходы. Для текста от других участников, не внедряющих водяные знаки, нужны иные методы, например post hoc detectionобнаружение ИИ-текста по косвенным признакам уже после его создания, без опоры на водяной знак. Распространение открытых моделей создаёт проблему: принудительно внедрять водяные знаки в децентрализованно развёрнутых моделях сложно.
Среди ограничений названа уязвимость генеративных водяных знаков к атакам кражи, подмены и удаления — это область продолжающихся исследований. Водяные знаки ослабляются редактированием текста, например перефразированием через LLM, хотя это обычно значительно меняет текст; оценки устойчивости к редактированию и перефразированию приведены в разделе C.6 Supplementary Information.
Что из этого следует на практике
Недисторсионность — это утверждение о распределении выходных токенов в среднем по случайному зерну, а не гарантия неизменности поведения модели. Даже когда схема сохраняет распределение на уровне токена, тот же механизм выбора токенов определяет действия агента: какой инструмент он вызовет и какие аргументы передаст. Поэтому проверять поведение модели нужно после включения SynthID, а не полагаться на то, что оно останется прежним.
Предсказуемость схемы для инъекции держится на детерминированности g-значений: они вычисляются из хеша токена, номера слоя и секретного ключа, а ключи для n-грамм зависят от предыдущих токенов. При известном или подобранном ключе преимущество одних токенов над другими можно рассчитать заранее.
Детектируемость зависит от длины текста: первые ngram_len - 1 позиций не дают вклада, а всё после первого EOS исключается. На коротких текстах свидетельств меньше, и статистическая уверенность ниже.
Порог принятия решения о наличии водяного знака в коде не фиксирован — его предлагается настраивать под свои нужды, а score лежит в диапазоне [0, 1].
При повторяющемся контексте наложение метки пропускается: вместо обновлённых скоров берутся исходные. Это влияет и на генерацию, и на детекцию — повторённые контексты исключаются из подсчёта через маску.
Выбор уровня non-distortion — компромисс: слабые уровни снижают качество и разнообразие текста, сильные уменьшают обнаруживаемость и увеличивают вычислительную сложность.
Где смотреть в коде
- logits_processing.py: _check_input_ids_shape
- detector_bayesian.py: __init__
- detector_mean.py: mean_score
- synthid_mixin.py: _construct_warper_list
- detector_bayesian.py: noise
- logits_processing.py: get_gvals
- detector_bayesian.py: __call__
- detector_bayesian.py: l2_loss