← Back to Guides
GuideFor: AI Engineers, ML Engineers, Platform Engineers, AI Systems Architects

How SynthID Works: Build a Watermark in Python

Build the green-list watermark on GPT-2 and detect it from text alone at p = 3.07e-56. Then find out how few tokens it needs and how much editing it survives.

#tutorial#intermediate#watermarking#synthid#transformers#gpt-2#ai-safety

Google's SynthID marks Gemini's output so it can be identified later, and the mechanism underneath it is simpler than the coverage suggests. This tutorial builds that class of text watermark from scratch in Python, on GPT-2, and then detects it from text alone at p = 3.07e-56. Intermediate level, about 45 minutes.

What you'll build

By the end of this tutorial you will run this:

code
          text  scored  green    rate        z           p  verdict         plain     199     50   25.1%     0.04    4.84e-01  no watermark   watermarked     199    146   73.4%    15.76    3.07e-56  WATERMARKED     wrong key     199     49   24.6%    -0.12    5.49e-01  no watermark

Three pieces of text. One was written by a model with a watermark switched on, one by the same model with it switched off. The detector reads only the text, never the token ids the model emitted, and separates them with a p-value of 3.07e-56.

The third row is the control, and it is the row that makes the other two mean anything. It scores the same watermarked text with the wrong secret key, and the signal vanishes. Whatever the detector is locking onto, it lives in the key.

By the end you will have a watermarker that biases token choices with a secret key, and a detector that pulls the watermark back out of plain text. You will also have measured answers to the two questions that decide whether any of this is usable in your system: how much text detection needs, and how much editing it survives.

This is an intermediate tutorial. It assumes you write Python and have loaded a model with Hugging Face transformers before. It does not assume you know what a logits processor is. Budget about 45 minutes, most of which is a 548 MB model download.

Verified against Python 3.13.9, transformers 5.15.0, torch 2.13.0+cpu, and numpy 2.5.2 on Windows, on 2026-08-17.

One thing to be clear about before you start, because a lot of writing on this topic blurs it. You are going to build the green-list watermark from Kirchenbauer et al. (2023). That is not the algorithm Google ships. SynthID-Text uses tournament sampling, a different mechanism from a different lineage. The green list is the right thing to build first because it is the mechanism you can implement, detect, and break in an afternoon, and because it is what Hugging Face transformers ships natively so you can check your work against a reference. The closing section explains how the real thing differs.

Prerequisites

You need Python 3.13 and about 1.5 GB of free disk for the model and the wheels. There is no API key and no paid service anywhere in this tutorial. GPT-2 is MIT licensed and downloads without authentication.

Create the project and a virtual environment:

code
mkdir synthid-labcd synthid-labpython -m venv .venv

Activate it. On macOS or Linux:

code
source .venv/bin/activate

On Windows PowerShell:

code
.venv\Scripts\Activate.ps1

If that is refused on a fresh Windows machine, that is the execution policy talking. Nothing to do with this project. Set-ExecutionPolicy -Scope Process RemoteSigned clears it for the current shell only.

Every command in this tutorial runs from inside synthid-lab with this environment active. If you come back to it in a new terminal, activate it again before anything else.

Create requirements.txt:

code
transformers==5.15.0torch==2.13.0numpy==2.5.2

Install:

code
pip install -r requirements.txt

Now verify the install before writing any code. This is the step that catches a broken environment while it is still cheap to fix. Create check_env.py:

code
"""Confirm the pinned stack is the one actually active."""import sysimport numpyimport torchimport transformersprint(sys.version.split()[0], torch.__version__,      transformers.__version__, numpy.__version__)

Run it:

code
python check_env.py

Expected output:

code
3.13.9 2.13.0+cpu 5.15.0 2.5.2

Your Python patch number may differ; anything on 3.13 is fine. Torch also prints a build suffix that is not part of the pin: +cpu here, +cu128 or similar on a CUDA machine. Either is fine. The transformers, torch and numpy versions themselves should match. If they do not, you have a stale environment active.

You do not need scipy. The one statistic this tutorial computes needs a normal tail probability, and math.erfc in the standard library gives it in a single line with better precision far out in the tail than subtracting a CDF from one.

A note on output for the rest of the tutorial. Every expected-output block below shows standard output only. On first run transformers also writes a model-loading progress bar to standard error, and huggingface_hub prints a notice about unauthenticated requests. Both are harmless and neither is shown below.

How Kirchenbauer green-list watermarking works

A language model does not choose the next word. It produces a score for every token in its vocabulary at once, all 50,257 of them for GPT-2, and a sampler turns those scores into one choice. Watermarking lives entirely in the gap between those two stages.

The whole technique rests on one fact about samplers: they usually have real freedom. When the model is deciding how to end "my favourite tropical fruits are mango and", several continuations are close to equally good. Nudging that choice costs almost nothing in quality, because you are picking between options the model already considered acceptable.

So: before the sampler runs, split the vocabulary in two. Call one part the green list and the other the red list. Add a small constant to the score of every green token. Do that at every step and the finished text will contain more green tokens than chance predicts.

The split cannot be fixed. A fixed split would be a word blacklist, and anyone could recover it by reading enough output. That secrecy is the whole mechanism, and it is a recurring theme in why provenance signals matter for AI engineers. So the split is derived fresh at every step from two things: a secret key, and the token that was just generated. Without the key the resulting pattern is noise. With the key it reproduces perfectly, and that is what makes detection possible.

mermaid
flowchart LR
    K["Secret key"] --> S["Seed = key x previous token id"]
    P["Previous token"] --> S
    S --> V["Shuffle all 50257 token ids"]
    V --> G["Green list<br/>first 12564 ids"]
    V --> R["Red list<br/>other 37693 ids"]
    G --> B["Add delta to their scores"]
    R --> N["Leave their scores alone"]
    B --> M["Sampler picks one token"]
    N --> M

    style K fill:#C2185B,color:#FFFFFF
    style P fill:#95A5A6,color:#FFFFFF
    style S fill:#7B68EE,color:#FFFFFF
    style V fill:#4A90E2,color:#FFFFFF
    style G fill:#6BCF7F,color:#2C2C2A
    style R fill:#E74C3C,color:#FFFFFF
    style B fill:#6BCF7F,color:#2C2C2A
    style N fill:#FFA07A,color:#2C2C2A
    style M fill:#4A90E2,color:#FFFFFF

The diagram covers one generation step. Both knobs carry their names from the literature, so keep the names:

  • gamma is the fraction of the vocabulary that is green. This tutorial uses 0.25.
  • delta is the constant added to green scores. This tutorial uses 2.0.

Detection reverses the same procedure. Take a candidate text, tokenise it, and for each token ask: given the token before it, and given the key, was this token green? Count the hits. If gamma is 0.25 then unwatermarked text should score about 25% green. Watermarked text scores far higher, and a one-proportion z-test turns that gap into a p-value.

Step 1: Inspect GPT-2's next-token probability distribution

Goal. Print the model's next-token distribution at a single position and see how much of the probability mass is genuinely contested.

Why this step. Every claim later in this tutorial depends on the sampler having room to be nudged. If the model were confident about every token, adding a small bias would either change nothing or wreck the text. Before building anything, confirm the room exists and get a feel for how much.

Create explore.py:

code
"""Show how much freedom GPT-2 has at a single generation step."""import torchfrom transformers import AutoModelForCausalLM, AutoTokenizerMODEL_ID = "openai-community/gpt2"tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)model.eval()prompt = "My favourite tropical fruits are mango and"inputs = tokenizer(prompt, return_tensors="pt")with torch.no_grad():    logits = model(**inputs).logitsnext_token_logits = logits[0, -1, :]probs = torch.softmax(next_token_logits, dim=-1)top = torch.topk(probs, k=10)print(f"vocab size: {model.config.vocab_size}")print(f"prompt: {prompt!r}")print("top 10 continuations:")for prob, token_id in zip(top.values, top.indices):    piece = tokenizer.decode([token_id])    print(f"  {token_id.item():>5}  {prob.item():6.2%}  {piece!r}")print(f"top-10 probability mass: {top.values.sum().item():.2%}")

Run it from inside synthid-lab:

code
python explore.py

Expected output. The first run downloads 548 MB and takes a few minutes.

code
vocab size: 50257prompt: 'My favourite tropical fruits are mango and'top 10 continuations:  49364  11.38%  ' mango'  45540  10.88%  ' pineapple'    442   3.63%  ' ch'    474   3.01%  ' j'    279   2.96%  ' p'  25996   2.10%  ' banana'    613   1.85%  ' pe'    269   1.71%  ' c'  25286   1.65%  ' pear'  28738   1.60%  ' lime'top-10 probability mass: 40.77%

What just happened. The top two candidates sit at 11.38% and 10.88%. A tiny nudge decides between them, and no reader could tell you which one the model "meant". Look at the last line too: the whole top ten accounts for only 40.77% of the probability mass, leaving nearly 60% spread across the other 50,247 tokens. All of that is unused freedom, and it is the raw material the watermark gets built from.

Vocab size 50,257, confirmed. That number matters in step 2.

Step 2: Derive a green list from a secret key

Goal. Write the function that turns a secret key plus the previous token into a reproducible list of green token ids.

Why this step. This function is the watermark. Everything after it is plumbing. Be explicit about the three properties it has to satisfy, because a break in any one silently destroys the scheme and no error will tell you: the same key and the same previous token must always give the same list, a different key must give an unrelated list, and the list must look like a random quarter of the vocabulary to anyone without the key.

Create watermark.py:

code
"""A green-list text watermark, built from scratch."""import torch# The millionth prime. Any large odd number works; this one is the default# that transformers' own WatermarkingConfig uses, so our green lists and# the library's green lists come out identical.DEFAULT_SECRET_KEY = 15485863class GreenListWatermarker:    """Splits the vocabulary into a green list and a red list at every step.    The split is a pure function of (secret key, previous token id), so the    generator and the detector derive the same lists without exchanging    anything except the key.    """    def __init__(        self,        vocab_size: int,        secret_key: int = DEFAULT_SECRET_KEY,        gamma: float = 0.25,        delta: float = 2.0,    ) -> None:        self.vocab_size = vocab_size        self.secret_key = secret_key        self.gamma = gamma        self.delta = delta        self.green_size = int(gamma * vocab_size)    def green_ids(self, prev_token_id: int) -> torch.Tensor:        """Token ids on the green list, given the token that came before."""        rng = torch.Generator(device="cpu")        rng.manual_seed((self.secret_key * int(prev_token_id)) % (2**64 - 1))        permutation = torch.randperm(self.vocab_size, generator=rng)        return permutation[: self.green_size]    def is_green(self, prev_token_id: int, token_id: int) -> bool:        """True if token_id is on the green list seeded by prev_token_id."""        green = self.green_ids(prev_token_id)        return bool((green == int(token_id)).any())

The seeded torch.Generator carries the whole scheme. A fresh generator gets created and seeded on every call, so the shuffle depends on the seed and nothing else: no global random state, no call ordering. Without that property the detector could not reproduce these lists later, out of order, from text alone.

Now check all three. Create try_greenlist.py:

code
"""Check that the green list is deterministic, key-dependent, and balanced."""from transformers import AutoTokenizerfrom watermark import GreenListWatermarkertokenizer = AutoTokenizer.from_pretrained("openai-community/gpt2")vocab_size = tokenizer.vocab_sizealice = GreenListWatermarker(vocab_size, secret_key=15485863)mallory = GreenListWatermarker(vocab_size, secret_key=99999989)prev_token_id = tokenizer.encode(" mango")[0]print(f"vocab size:  {vocab_size}")print(f"green size:  {alice.green_size}  (gamma = {alice.gamma})")print(f"prev token:  {prev_token_id} {tokenizer.decode([prev_token_id])!r}")first_call = alice.green_ids(prev_token_id)second_call = alice.green_ids(prev_token_id)print(f"same key, same prev token -> identical: "      f"{bool((first_call == second_call).all())}")other_key = mallory.green_ids(prev_token_id)overlap = len(set(first_call.tolist()) & set(other_key.tolist()))print(f"overlap with a wrong key: {overlap} of {alice.green_size} "      f"({overlap / alice.green_size:.1%})")for word in [" bananas", " papaya", " airplanes"]:    token_id = tokenizer.encode(word)[0]    verdict = "green" if alice.is_green(prev_token_id, token_id) else "red"    print(f"  {word!r:>12} -> {verdict}")

This bites people who swap the model out later, so note it now: try_greenlist.py sizes the watermarker from tokenizer.vocab_size, while steps 3 and 4 will size it from model.config.vocab_size. For GPT-2 both are 50,257, so the two halves derive the same permutations. They are separate objects and can disagree for other checkpoints. If they ever do, the generator and the detector build different green lists and detection silently returns noise. Step 8 prints both numbers to prove the equality holds here.

Run it:

code
python try_greenlist.py

Expected output:

code
vocab size:  50257green size:  12564  (gamma = 0.25)prev token:  49364 ' mango'same key, same prev token -> identical: Trueoverlap with a wrong key: 3127 of 12564 (24.9%)    ' bananas' -> red     ' papaya' -> green  ' airplanes' -> red

What just happened. All three hold. The same key twice gives an identical list. A different key overlaps by 24.9%, and two independent random quarters of a 50,257-token vocabulary would overlap by 25%, so the wrong key has told you nothing at all. No partial credit, no warmer-colder.

The last three lines make the same point from a different angle. " papaya" is green, " bananas" is red, and both are perfectly good things to say after " mango". The split ignores meaning completely. It has no idea what a mango is, and the invisibility comes free from that: an arbitrary quarter of the vocabulary leaves no stylistic fingerprint for a reader to catch.

watermark.py now holds one class with three methods. The next step adds a second class to the same file.

Step 3: Wrap the green list in a LogitsProcessor

Goal. Turn the green list into something model.generate() will call at every step.

Why this step. You cannot bias generation from outside the loop. transformers exposes exactly one hook for this: a LogitsProcessor, an object whose __call__ receives the scores for the whole vocabulary and returns modified scores. Implementing that interface is the difference between a green-list function and a working watermarker.

The contract is narrow and unforgiving. Your __call__ takes (input_ids, scores) and must return a tensor of the same shape and dtype it was given. scores has shape (batch_size, vocab_size).

Replace the single import torch line under the module docstring in watermark.py, so that the import block reads:

code
import torchfrom transformers import LogitsProcessor

Then append this class to the end of watermark.py. The assembled file appears in full at the end of this tutorial if you want to check yours against it:

code
class GreenListLogitsProcessor(LogitsProcessor):    """Adds delta to the logit of every green-list token, every step."""    def __init__(self, watermarker: GreenListWatermarker) -> None:        self.watermarker = watermarker    def __call__(        self, input_ids: torch.LongTensor, scores: torch.FloatTensor    ) -> torch.FloatTensor:        biased = scores.clone()        for row in range(input_ids.shape[0]):            prev_token_id = input_ids[row, -1].item()            green = self.watermarker.green_ids(prev_token_id)            biased[row, green.to(scores.device)] += self.watermarker.delta        return biased

scores.clone() earns its line: it avoids mutating a tensor the caller still owns. green.to(scores.device) earns its line for a worse reason. Build that index on CPU and assume, and the processor runs perfectly on your laptop and dies the first time it lands on a GPU box. The troubleshooting section quotes the exact error you get when it does.

Create try_bias.py to see the effect on one real distribution:

code
"""Show what the bias does to a single step's scores."""import torchfrom transformers import AutoModelForCausalLM, AutoTokenizerfrom watermark import GreenListLogitsProcessor, GreenListWatermarkerMODEL_ID = "openai-community/gpt2"tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)model.eval()prompt = "My favourite tropical fruits are mango and"inputs = tokenizer(prompt, return_tensors="pt")with torch.no_grad():    scores = model(**inputs).logits[:, -1, :]watermarker = GreenListWatermarker(model.config.vocab_size)processor = GreenListLogitsProcessor(watermarker)biased = processor(inputs["input_ids"], scores)print(f"shape in {tuple(scores.shape)} -> shape out {tuple(biased.shape)}")before = torch.softmax(scores[0], dim=-1)after = torch.softmax(biased[0], dim=-1)prev_token_id = inputs["input_ids"][0, -1].item()print(f"{'token':>22}  {'list':>5}  {'before':>7}  {'after':>7}")for token_id in torch.topk(before, k=8).indices:    colour = "green" if watermarker.is_green(prev_token_id, token_id) else "red"    piece = tokenizer.decode([token_id])    print(f"{piece!r:>22}  {colour:>5}  {before[token_id]:6.2%}  "          f"{after[token_id]:6.2%}")

Run it:

code
python try_bias.py

Expected output:

code
shape in (1, 50257) -> shape out (1, 50257)                 token   list   before    after              ' mango'    red  11.38%   5.13%          ' pineapple'    red  10.88%   4.91%                 ' ch'    red   3.63%   1.64%                  ' j'    red   3.01%   1.36%                  ' p'    red   2.96%   1.34%             ' banana'  green   2.10%   7.00%                 ' pe'    red   1.85%   0.84%                  ' c'    red   1.71%   0.77%

What just happened. The shape survived the round trip, which was the whole contract. Now look at the size of the effect: " banana" went from 2.10% to 7.00% and jumped from sixth place to first, while every red token roughly halved.

Notice how much bigger that is than the "2 to 3 percent nudge" this technique usually gets described with. Adding 2.0 to a logit multiplies the token's odds by e squared, about 7.4 times, before renormalisation. Green-list watermarking is distortionary by construction. It changes the distribution the model samples from, and you are paying that quality cost in exchange for detectability.

Turn delta down and you buy the quality back at the exact cost of detectability. There is no dial position that gives you both. Google went to a sampling-based design specifically to escape that dial. The closing section covers how.

Step 4: Generate watermarked and unwatermarked text

Goal. Produce two continuations from the same prompt and the same random seed, one with the processor attached and one without, and save both to disk.

Why this step. You need a matched pair to have anything to detect. Fixing the seed across both runs means any difference between the two texts is caused by the watermark and nothing else. Saving to disk matters more than it looks: it forces the detector in the next step to work from text, the way a real detector must, rather than from token ids it was handed by the generator.

Two things in the code below will be new. generate() wants a LogitsProcessorList, not a bare processor: it is an ordered list, and yours gets appended to the ones generate() builds internally from your other keyword arguments. Where yours lands in that order turns out to matter, and the troubleshooting section has the measurement. Passing logits_processor=None is also legal, and that is how the plain run is produced.

Create run_generate.py:

code
"""Generate one watermarked and one plain continuation from the same seed."""import textwrapfrom pathlib import Pathimport torchfrom transformers import (    AutoModelForCausalLM,    AutoTokenizer,    LogitsProcessorList,    set_seed,)from watermark import GreenListLogitsProcessor, GreenListWatermarkerMODEL_ID = "openai-community/gpt2"PROMPT = "The old lighthouse keeper had a rule about storms:"MAX_NEW_TOKENS = 200SEED = 7tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)tokenizer.pad_token_id = tokenizer.eos_token_idmodel = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)model.eval()watermarker = GreenListWatermarker(model.config.vocab_size)processors = LogitsProcessorList([GreenListLogitsProcessor(watermarker)])inputs = tokenizer(PROMPT, return_tensors="pt")prompt_len = inputs["input_ids"].shape[1]def continuation(logits_processor) -> str:    set_seed(SEED)    with torch.no_grad():        output = model.generate(            **inputs,            do_sample=True,            temperature=1.0,            top_k=50,            max_new_tokens=MAX_NEW_TOKENS,            pad_token_id=tokenizer.eos_token_id,            logits_processor=logits_processor,        )    return tokenizer.decode(output[0, prompt_len:], skip_special_tokens=True)plain = continuation(None)watermarked = continuation(processors)out_dir = Path("output")out_dir.mkdir(exist_ok=True)(out_dir / "plain.txt").write_text(plain, encoding="utf-8")(out_dir / "watermarked.txt").write_text(watermarked, encoding="utf-8")print(f"prompt: {PROMPT!r}")print(f"prompt tokens stripped: {prompt_len}")print()print("--- plain, first 220 chars ---")print(textwrap.fill(plain[:220], width=76))print()print("--- watermarked, first 220 chars ---")print(textwrap.fill(watermarked[:220], width=76))print()print(f"wrote {out_dir / 'plain.txt'} and {out_dir / 'watermarked.txt'}")

The textwrap.fill calls only keep the terminal preview readable. The files on disk hold the unwrapped text, and the detector reads the files.

Note output[0, prompt_len:]. The model returns the prompt followed by the continuation, and the prompt was not watermarked. Leaving it in would dilute every statistic in the rest of the tutorial with ten tokens of noise.

Run it:

code
python run_generate.py

Expected output. Two runs of 200 tokens each on CPU takes roughly a minute.

code
prompt: 'The old lighthouse keeper had a rule about storms:'prompt tokens stripped: 10--- plain, first 220 chars --- only when they got too close to the shore, would they go away. He livedwith two little children.  In 1959, he spent 25 years as a local policecaptain. During his eight years on the force he was often called a "dirty l--- watermarked, first 220 chars --- only when they struck it, and it never happened again. She had something tosay of the little green ship's recent woes, and those she saw, though, mightbe the latest to come: fire. The current star was burning. The darwrote output\plain.txt and output\watermarked.txt

On macOS and Linux the last line will show output/plain.txt with forward slashes.

What just happened. Both texts are fluent English and both are plausible continuations of the prompt. They share the first three words, " only when they", then diverge at the fourth. That fourth position is where the bias first got large enough to change the sampler's pick. Nothing about the watermarked version announces itself. Read on its own it runs a little more repetitive, and that is the step 3 distortion surfacing in prose, but no human could point at a specific word and call it.

You now have output/plain.txt and output/watermarked.txt on disk, 200 generated tokens each, and no record of which token ids produced them.

Step 5: Detect the watermark with a z-test

Goal. Recover the watermark from text alone and turn the green-token count into a p-value.

Why this step. A green-token count means nothing until you know how many tokens produced it. 60% green in a twelve-token tweet is unremarkable; 60% green across 500 tokens is not something chance produces. The z-test converts a count into a statement about how surprised you should be. Bring a bare green rate into a dispute and you have an argument. Bring 3.07e-56 and you have a measurement someone has to attack on its own terms.

Under the null hypothesis, meaning text written with no knowledge of the green lists, each scored token is green with probability gamma, independently. So across T scored tokens the green count has mean gamma * T and variance T * gamma * (1 - gamma), and

code
z = (green_count - gamma * T) / sqrt(T * gamma * (1 - gamma))

Create detect.py:

code
"""Detect the green-list watermark with a one-proportion z-test."""import mathfrom dataclasses import dataclassfrom watermark import GreenListWatermarker@dataclassclass Detection:    scored: int    green: int    green_rate: float    z: float    p_value: float    def verdict(self, threshold: float = 4.0) -> str:        return "WATERMARKED" if self.z > threshold else "no watermark"def detect(token_ids: list[int], watermarker: GreenListWatermarker) -> Detection:    """Score every token that has a predecessor inside the candidate text."""    scored = 0    green = 0    for prev_token_id, token_id in zip(token_ids, token_ids[1:]):        scored += 1        if watermarker.is_green(prev_token_id, token_id):            green += 1    if scored == 0:        raise ValueError("need at least 2 tokens to score a watermark")    gamma = watermarker.gamma    expected = gamma * scored    z = (green - expected) / math.sqrt(scored * gamma * (1.0 - gamma))    p_value = 0.5 * math.erfc(z / math.sqrt(2.0))    return Detection(scored, green, green / scored, z, p_value)

The zip(token_ids, token_ids[1:]) is where the off-by-one lives. The first token of the text has no predecessor inside the text, so it cannot be scored. A 200-token text yields 199 scored tokens, not 200. Getting this wrong shifts z by a few percent, which matters at short lengths.

Steps 5 through 8 all read the files in output/, so step 4 has to have run first. And note that the detector re-encodes text that was decoded from token ids. That round trip is not guaranteed in general to return the same token count; for this text it does, which is why 200 tokens out gives 199 scored.

The threshold: float = 4.0 default is not arbitrary either. A z above 4 is the conventional cut-off in this literature, and the reading after the run below says what false positive rate it buys you.

Create run_detect.py:

code
"""Run the detector over both saved texts, using only the text itself."""from pathlib import Pathfrom transformers import AutoTokenizerfrom detect import detectfrom watermark import GreenListWatermarkertokenizer = AutoTokenizer.from_pretrained("openai-community/gpt2")watermarker = GreenListWatermarker(tokenizer.vocab_size)print(f"{'text':>14}  {'scored':>6}  {'green':>5}  {'rate':>6}  "      f"{'z':>7}  {'p':>10}  verdict")for name in ["plain", "watermarked"]:    text = Path("output", f"{name}.txt").read_text(encoding="utf-8")    token_ids = tokenizer.encode(text)    result = detect(token_ids, watermarker)    print(f"{name:>14}  {result.scored:>6}  {result.green:>5}  "          f"{result.green_rate:>6.1%}  {result.z:>7.2f}  "          f"{result.p_value:>10.2e}  {result.verdict()}")wrong_key = GreenListWatermarker(tokenizer.vocab_size, secret_key=99999989)text = Path("output", "watermarked.txt").read_text(encoding="utf-8")result = detect(tokenizer.encode(text), wrong_key)print(f"{'wrong key':>14}  {result.scored:>6}  {result.green:>5}  "      f"{result.green_rate:>6.1%}  {result.z:>7.2f}  "      f"{result.p_value:>10.2e}  {result.verdict()}")

Run it:

code
python run_detect.py

Expected output:

code
          text  scored  green    rate        z           p  verdict         plain     199     50   25.1%     0.04    4.84e-01  no watermark   watermarked     199    146   73.4%    15.76    3.07e-56  WATERMARKED     wrong key     199     49   24.6%    -0.12    5.49e-01  no watermark

What just happened. The unwatermarked text scored 25.1% green against an expected 25.0%, a z of 0.04. Real measurements do not come much closer to zero signal than that. The watermarked text scored 73.4%, a z of 15.76. For context, the conventional threshold is z above 4, which corresponds to a false positive rate around 3 in 100,000.

Row three is why I bothered running it at all. Swap the key 15485863 for 99999989 and the same string comes back at 24.6% green, a z of -0.12, which is chance. Nothing about the text changed between rows two and three. Prose that was merely machine-flavoured or repetitive would have lit row three up as well, and it came back at noise. The whole signal is sitting inside 15485863.

Step 6: Find the minimum text length for watermark detection

Goal. Find the shortest prefix of the watermarked text that still clears the detection threshold.

Why this step. "Watermarking does not work on short text" is repeated everywhere, usually attached to a specific number. Those numbers are mostly unsourced. You now have a working detector, so you can stop taking anyone's word for it. The threshold for your own configuration is the only number that actually governs your system.

Create run_sweep.py:

code
"""Find the shortest text this watermark can actually be detected in."""from pathlib import Pathfrom transformers import AutoTokenizerfrom detect import detectfrom watermark import GreenListWatermarkerTHRESHOLD = 4.0LENGTHS = [10, 20, 30, 40, 50, 75, 100, 150, 200]tokenizer = AutoTokenizer.from_pretrained("openai-community/gpt2")watermarker = GreenListWatermarker(tokenizer.vocab_size)text = Path("output", "watermarked.txt").read_text(encoding="utf-8")all_token_ids = tokenizer.encode(text)print(f"{'length':>6}  {'scored':>6}  {'green':>5}  {'rate':>6}  {'z':>7}  "      f"{'p':>10}  verdict")crossing = Nonefor length in LENGTHS:    if length > len(all_token_ids):        break    result = detect(all_token_ids[:length], watermarker)    if crossing is None and result.z > THRESHOLD:        crossing = length    print(f"{length:>6}  {result.scored:>6}  {result.green:>5}  "          f"{result.green_rate:>6.1%}  {result.z:>7.2f}  "          f"{result.p_value:>10.2e}  {result.verdict()}")print()print(f"first length in the sweep to clear z > {THRESHOLD}: {crossing} tokens")

Run it:

code
python run_sweep.py

Expected output:

code
length  scored  green    rate        z           p  verdict    10       9      7   77.8%     3.66    1.28e-04  no watermark    20      19     15   78.9%     5.43    2.81e-08  WATERMARKED    30      29     24   82.8%     7.18    3.41e-13  WATERMARKED    40      39     31   79.5%     7.86    1.95e-15  WATERMARKED    50      49     40   81.6%     9.16    2.71e-20  WATERMARKED    75      74     53   71.6%     9.26    1.00e-20  WATERMARKED   100      99     69   69.7%    10.27    4.78e-25  WATERMARKED   150     149    110   73.8%    13.76    2.10e-43  WATERMARKED   200     199    146   73.4%    15.76    3.07e-56  WATERMARKED

Then, after a blank line, the same run prints its conclusion:

code
first length in the sweep to clear z > 4.0: 20 tokens

What just happened. At delta 2.0 and gamma 0.25, this watermark becomes detectable somewhere between 10 and 20 tokens, which is roughly fifteen words at GPT-2's average token length. The "200 words minimum" figure that circulates online is off by more than an order of magnitude, appears in no primary source I could find, and should be discarded.

The ten-token row teaches the more important lesson. Its green rate is 77.8%, higher than the 200-token row's 73.4%, and yet it is correctly reported as undetected. Rate is not evidence. With only nine scored tokens, seven greens is well within what chance produces, so z comes in at 3.66 and the null hypothesis survives.

Do not port the number 20 anywhere. It is one sample from one text, and the crossing point moves with both. It also describes this configuration only. I have not measured the curve at delta 1.0, so I am not going to tell you where it lands.

If you want that number, copy the project first, change the delta default in the copy's watermark.py, then re-run run_generate.py and run_sweep.py there. Both, in that order. The sweep reads text from disk, so it will happily report the old numbers until the text is regenerated.

Do not make that change in place and carry on. Steps 7 and 8 both read output/watermarked.txt, and their numbers only reproduce for the delta 2.0 text. Step 8's assertions would still pass, because both implementations would agree on whatever text is on disk. Nothing would tell you the ground had moved. That is the same silent failure step 2 warned about, and it is worth having met it twice.

Step 7: Test how much editing the watermark survives

Goal. Break it. Replace a growing fraction of the tokens at random and watch the z-score fall.

Why this step. Somebody pastes your model's output into a document, cuts a third of it, and reworks a paragraph. That is the normal case, not the adversarial one, and you need to know what survives it. This is also the attack surface. The same curve tells an adversary exactly how much work removal costs.

One deliberate simplification below: this script edits token ids directly and never decodes back to text, which is the opposite of what step 4 insisted on. That is so the edit fraction can be controlled exactly. A real rewrite would be messier and would change the token count as well.

Create run_robustness.py:

code
"""Edit the watermarked text and watch the evidence decay."""import randomfrom pathlib import Pathfrom transformers import AutoTokenizerfrom detect import detectfrom watermark import GreenListWatermarkerFRACTIONS = [0.0, 0.05, 0.10, 0.20, 0.30, 0.50]SEED = 7tokenizer = AutoTokenizer.from_pretrained("openai-community/gpt2")watermarker = GreenListWatermarker(tokenizer.vocab_size)text = Path("output", "watermarked.txt").read_text(encoding="utf-8")original = tokenizer.encode(text)print(f"{'edited':>7}  {'green':>5}  {'rate':>6}  {'z':>7}  "      f"{'p':>10}  verdict")for fraction in FRACTIONS:    rng = random.Random(SEED)    edited = list(original)    positions = rng.sample(range(len(edited)), int(fraction * len(edited)))    for position in positions:        edited[position] = rng.randrange(tokenizer.vocab_size)    result = detect(edited, watermarker)    print(f"{fraction:>7.0%}  {result.green:>5}  {result.green_rate:>6.1%}  "          f"{result.z:>7.2f}  {result.p_value:>10.2e}  {result.verdict()}")

Run it:

code
python run_robustness.py

Expected output:

code
 edited  green    rate        z           p  verdict     0%    146   73.4%    15.76    3.07e-56  WATERMARKED     5%    135   67.8%    13.96    1.44e-44  WATERMARKED    10%    129   64.8%    12.97    8.60e-39  WATERMARKED    20%    111   55.8%    10.03    5.79e-24  WATERMARKED    30%     98   49.2%     7.90    1.41e-15  WATERMARKED    50%     66   33.2%     2.66    3.90e-03  no watermark

What just happened. The watermark survives light editing comfortably. Replacing every tenth token still leaves z at 12.97. Even a 30% replacement, a heavy rewrite by any standard, leaves z at 7.90 and well clear of the threshold. Detection fails only at 50%, and by then half the tokens are random vocabulary items and the text is no longer readable English.

Each replacement damages the count twice over. The replaced token is unlikely to be green, and it then becomes the seed for the next token's green list, turning that token's score into a coin flip as well. So the green rate falls faster than the edit fraction does.

A graceful decay curve sounds like good news and mostly is not. Yes, the watermark shrugs off ordinary copy-editing. But a smooth curve also means an attacker never needs a clever exploit. They just grind. Jovanović et al. (2024) put a number on the grinding: under $50 of API queries was enough to both spoof and scrub schemes in this family, at over 80% average success. That attack targets green-list watermarks: the thing you just built.

Step 8: Validate against transformers' WatermarkDetector

Goal. Find out whether you actually got it right. transformers ships the same algorithm, so make the two green counts agree to the token.

Why this step. Everything so far only proves your code agrees with itself. Your generator and your detector share the same green_ids function, so a bug in it would cancel out and still produce a beautiful z-score. The only way to rule that out is to check against an independent implementation of the same algorithm.

transformers ships one. WatermarkLogitsProcessor implements this exact scheme, and under seeding_scheme="lefthash" it seeds with hashing_key * previous_token_id. That is precisely what GreenListWatermarker.green_ids does, and it is no coincidence: it is why step 2 took the millionth prime as its default key. If both implementations are right, their green counts have to agree to the token.

Create run_builtin.py:

code
"""Cross-check our detector against the one shipped in transformers."""from pathlib import Pathimport torchfrom transformers import (    AutoModelForCausalLM,    AutoTokenizer,    WatermarkDetector,    WatermarkingConfig,)from detect import detectfrom watermark import GreenListWatermarkerMODEL_ID = "openai-community/gpt2"tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)model = AutoModelForCausalLM.from_pretrained(MODEL_ID, dtype=torch.float32)config = WatermarkingConfig(    greenlist_ratio=0.25,    bias=2.0,    hashing_key=15485863,    seeding_scheme="lefthash",    context_width=1,)library_detector = WatermarkDetector(    model_config=model.config, device="cpu", watermarking_config=config)ours = GreenListWatermarker(model.config.vocab_size)print(f"tokenizer.vocab_size={tokenizer.vocab_size} "      f"model.config.vocab_size={model.config.vocab_size}")print(f"{'text':>14}  {'ours: green/T':>15}  {'theirs: green/T':>15}  "      f"{'ours z':>7}  {'theirs z':>8}")for name in ["plain", "watermarked"]:    text = Path("output", f"{name}.txt").read_text(encoding="utf-8")    token_ids = tokenizer.encode(text)    mine = detect(token_ids, ours)    batch = torch.tensor([token_ids], dtype=torch.long)    theirs = library_detector(batch, z_threshold=4.0, return_dict=True)    their_green = int(theirs.num_green_tokens[0])    their_scored = int(theirs.num_tokens_scored[0])    their_z = float(theirs.z_score[0])    print(f"{name:>14}  {f'{mine.green}/{mine.scored}':>15}  "          f"{f'{their_green}/{their_scored}':>15}  "          f"{mine.z:>7.2f}  {their_z:>8.2f}")    assert mine.green == their_green, "green counts disagree"    assert mine.scored == their_scored, "scored-token counts disagree"print()print("green counts and z-scores agree: our implementation is bit-compatible")print("with transformers' WatermarkLogitsProcessor under seeding_scheme='lefthash'")

Run it:

code
python run_builtin.py

Expected output:

code
tokenizer.vocab_size=50257 model.config.vocab_size=50257          text    ours: green/T  theirs: green/T   ours z  theirs z         plain           50/199           50/199     0.04      0.04   watermarked          146/199          146/199    15.76     15.76green counts and z-scores agree: our implementation is bit-compatiblewith transformers' WatermarkLogitsProcessor under seeding_scheme='lefthash'

What just happened. Both assertions passed. Two independently written implementations counted 146 green tokens out of 199 and computed the same z to two decimals. Your green lists are the library's green lists, so the 15.76 from step 5 was measuring the watermark. A shared bug would have surfaced here as a mismatch.

The first printed line is part of the check, not debug noise: it confirms tokenizer.vocab_size and model.config.vocab_size really are the same 50,257 that both halves have been assuming. And although the script only instantiates WatermarkDetector, that class builds a WatermarkLogitsProcessor internally and derives its green lists from it, so the comparison reaches the generator side too. Both implementations call their own equivalent of green_ids, and the counts match.

The library spells the parameters differently, so map them once: greenlist_ratio is gamma, bias is delta, hashing_key is your secret key, and context_width=1 means the green list is seeded from one preceding token, matching the single prev_token_id your green_ids takes.

If you extend this comparison, watch ignore_repeated_ngrams on WatermarkDetector. It defaults to False, and only that default matches the detector you wrote. Set it to True and it counts each distinct bigram once, and the two implementations will legitimately disagree.

Troubleshooting LogitsProcessor and detection errors

These are the failures you are most likely to hit, with the actual strings.

ValueError: are passed to the logits processor

Reproduced on the pinned stack. You will see ValueError: Make sure that all the required parameters: ['self', 'input_ids', 'scores'] for <class 'type'> are passed to the logits processor.

Cause. You passed the class instead of an instance: LogitsProcessorList([GreenListLogitsProcessor]). LogitsProcessorList inspects each entry's __call__ signature, and on an unbound class it sees three parameters including self, decides your processor needs extra keyword arguments, and refuses. The <class 'type'> in the message is the tell.

Fix. Construct it: LogitsProcessorList([GreenListLogitsProcessor(watermarker)]).

IndexError: Dimension out of range

Reproduced on the pinned stack.

code
IndexError: Dimension out of range (expected to be in range of [-1, 0], but got 1)

Cause. Your __call__ returned a tensor with the wrong rank, usually by returning scores[0] instead of the full (batch_size, vocab_size) tensor. Generation then indexes a dimension that no longer exists.

Fix. Always return a tensor of the same shape you were given. Assert it while developing: assert out.shape == scores.shape.

RuntimeError: probability tensor contains inf, nan or element < 0

code
RuntimeError: probability tensor contains either `inf`, `nan` or element < 0

Cause. The processor wrote a non-finite value into scores, and sampling then failed on the resulting probabilities. The usual source is bad index arithmetic writing over the whole row, or adding to a value that was already negative infinity in a way that produces nan. Widely reported against torch.multinomial; not reproduced here, because the implementation in this tutorial does not hit it.

Fix. Check torch.isfinite(biased).all() inside __call__ while developing, and confirm your index tensor holds token ids rather than positions.

RuntimeError: Expected all tensors to be on the same device

On a GPU box you will see RuntimeError: Expected all tensors to be on the same device, but found at least two devices, cuda:0 and cpu!

Cause. The green-list index tensor was built on CPU while scores lives on the GPU. This tutorial was verified on CPU only, so this is cited from the upstream issue trackers rather than reproduced here.

Fix. This is why GreenListLogitsProcessor.__call__ writes green.to(scores.device) rather than green. Build device-dependent tensors inside __call__, never in __init__.

ValueError: need at least 2 tokens to score a watermark

code
ValueError: need at least 2 tokens to score a watermark

Cause. Your own guard in detect.py, raised on a text of fewer than two tokens. The first token has no predecessor, so there is nothing to score.

Fix. Nothing to fix. The guard is doing its job, so handle it at the call site instead of lowering it. A one-token detection result would be meaningless.

LogitsProcessor runs before temperature and top-k, not after

This one produces no error at all. Dangerous for exactly that reason. I only went looking for it because a temperature sweep returned numbers I could not explain, and the explanation turned out to be ordering rather than statistics. It is natural to assume that by the time your processor runs, top_k=50 has already restricted the candidates and temperature has already scaled the scores. On transformers 5.15.0, neither is true. Custom processors are appended to the end of the merged list, but the temperature and top-k warpers run after that list, so your __call__ receives the model's raw logits, untouched.

Measured on the pinned stack: with top_k=50, a probe processor still saw all 50,257 scores finite, and at temperature=0.5 it still saw the model's raw maximum logit unchanged. If the sampling stack itself is unfamiliar, how inference and serving actually transform logits covers the surrounding machinery.

This bites twice. Your delta lands before temperature scaling, so temperature divides it downstream, and delta 2.0 at temperature 0.5 behaves like a 4.0 in probability space. Your bias can also promote a token that top-k would otherwise have cut, because top-k has not run yet. Change the temperature and you must re-measure. Delta will not mean what it meant before.

Warnings you can ignore

Warning: You are sending unauthenticated requests to the HF Hub. Please set a HF_TOKEN to enable higher rate limits and faster downloads. is informational. GPT-2 is not gated and no token is needed.

On Windows you may also get a UserWarning from huggingface_hub saying its cache system uses symlinks and your machine does not support them. Downloads still work, they just use more disk. Set HF_HUB_DISABLE_SYMLINKS_WARNING=1 to silence it.

How the watermarker and detector fit together

You have built two halves that never communicate except through a shared secret.

mermaid
flowchart TD
    KEY["Secret key<br/>15485863"]

    subgraph GEN["Generation - run_generate.py"]
        P["Prompt"] --> M["GPT-2 forward pass"]
        M --> L["Raw logits<br/>50257 scores"]
        L --> W["GreenListLogitsProcessor<br/>adds delta to green ids"]
        W --> SM["Temperature, top-k, sampler"]
        SM --> T["Watermarked text on disk"]
    end

    subgraph DET["Detection - run_detect.py"]
        C["Candidate text"] --> TK["Tokenizer"]
        TK --> CNT["Count green tokens<br/>over each adjacent pair"]
        CNT --> Z["z-score and p-value"]
        Z --> VD{"z above 4?"}
    end

    KEY -.-> W
    KEY -.-> CNT
    T -.-> C

    style KEY fill:#C2185B,color:#FFFFFF
    style P fill:#95A5A6,color:#FFFFFF
    style M fill:#4A90E2,color:#FFFFFF
    style L fill:#4A90E2,color:#FFFFFF
    style W fill:#6BCF7F,color:#2C2C2A
    style SM fill:#7B68EE,color:#FFFFFF
    style T fill:#FFD93D,color:#2C2C2A
    style C fill:#FFD93D,color:#2C2C2A
    style TK fill:#98D8C8,color:#2C2C2A
    style CNT fill:#6BCF7F,color:#2C2C2A
    style Z fill:#7B68EE,color:#FFFFFF
    style VD fill:#FFA07A,color:#2C2C2A

Generation needs logits, so it runs inside your serving stack, in the same process as the model. Detection needs a string, so it runs anywhere a string goes: a background job, a support ticket queue, somebody's laptop. Two things pass between the halves. The key, which nobody outside them may hold, and the text, which goes out into the world and comes back edited. Step 4 made you write to disk precisely so the detector could only ever see the second one.

Whoever holds 15485863 can detect. Nobody else can, and that is not an implementation gap someone closes in the next release. Publishing the key publishes the recipe for stripping and forging the mark in the same breath, so a provider who wants detection to keep working has to keep it secret. What you end up with is not public verification. Google can tell you whether a string came out of Gemini. You, holding the same string and no key, cannot, and nothing you built in the last 45 minutes changes that.

The complete watermarker and detector code

Final file tree, excluding the virtual environment:

code
synthid-lab/├── requirements.txt├── check_env.py├── watermark.py├── detect.py├── explore.py├── try_greenlist.py├── try_bias.py├── run_generate.py├── run_detect.py├── run_sweep.py├── run_robustness.py├── run_builtin.py└── output/    ├── plain.txt    └── watermarked.txt

Every file except watermark.py was shown complete in the step that created it. watermark.py was built across steps 2 and 3, so here it is in full:

code
"""A green-list text watermark, built from scratch."""import torchfrom transformers import LogitsProcessor# The millionth prime. Any large odd number works; this one is the default# that transformers' own WatermarkingConfig uses, so our green lists and# the library's green lists come out identical.DEFAULT_SECRET_KEY = 15485863class GreenListWatermarker:    """Splits the vocabulary into a green list and a red list at every step.    The split is a pure function of (secret key, previous token id), so the    generator and the detector derive the same lists without exchanging    anything except the key.    """    def __init__(        self,        vocab_size: int,        secret_key: int = DEFAULT_SECRET_KEY,        gamma: float = 0.25,        delta: float = 2.0,    ) -> None:        self.vocab_size = vocab_size        self.secret_key = secret_key        self.gamma = gamma        self.delta = delta        self.green_size = int(gamma * vocab_size)    def green_ids(self, prev_token_id: int) -> torch.Tensor:        """Token ids on the green list, given the token that came before."""        rng = torch.Generator(device="cpu")        rng.manual_seed((self.secret_key * int(prev_token_id)) % (2**64 - 1))        permutation = torch.randperm(self.vocab_size, generator=rng)        return permutation[: self.green_size]    def is_green(self, prev_token_id: int, token_id: int) -> bool:        """True if token_id is on the green list seeded by prev_token_id."""        green = self.green_ids(prev_token_id)        return bool((green == int(token_id)).any())class GreenListLogitsProcessor(LogitsProcessor):    """Adds delta to the logit of every green-list token, every step."""    def __init__(self, watermarker: GreenListWatermarker) -> None:        self.watermarker = watermarker    def __call__(        self, input_ids: torch.LongTensor, scores: torch.FloatTensor    ) -> torch.FloatTensor:        biased = scores.clone()        for row in range(input_ids.shape[0]):            prev_token_id = input_ids[row, -1].item()            green = self.watermarker.green_ids(prev_token_id)            biased[row, green.to(scores.device)] += self.watermarker.delta        return biased

How SynthID's tournament sampling differs from a green list

You have built the green-list watermark. Google's SynthID-Text is a different mechanism. The two schemes share a goal and almost nothing else.

The green list modifies the logits. It adds delta before the softmax, which changes the distribution the model samples from. That is why step 3 showed " banana" tripling in probability, and it is why the technique is called distortionary: the watermarked model is not the same model any more.

SynthID-Text leaves the logits alone and modifies the sampling instead. It draws multiple candidate tokens from the model's own unmodified distribution, then runs a single-elimination tournament between them, where a keyed pseudorandom function decides each match. The winner is emitted. The candidates all came from the true distribution, so the scheme can be configured to be non-distortionary: it preserves the model's output distribution in expectation. The green list cannot do that at any delta.

I went into this expecting tournament sampling to be something you could bolt onto what you just built. You cannot, and the reason is worth understanding. Once you accept that the mark lives in the choice between candidates instead of in the scores, the clean z-test goes with it. There is no closed form as tidy as the one you wrote, and Google's strongest detector is a trained Bayesian model. What you buy for that is a nearly free watermark: Gemma 7B-IT generated at 15.527 ms per token unwatermarked and 15.615 ms with 30 tournament layers, a 0.57% increase.

You do not get out of paying, you just pay somewhere else. The green list bills you in text quality up front, and hands you a detector that fits in one function with one call to math.erfc. SynthID bills you 0.57% of generation latency, and hands you a Bayesian model somebody has to train, version alongside the generator, and recalibrate whenever the sampling config moves. Only one of those bills shows up on a model card.

Your own numbers contradict two things people repeat about SynthID constantly:

  • "SynthID needs about 200 words." I went looking for this in the Nature paper, DeepMind's blog and Anthropic's write-up. It is in none of them. Your own sweep crossed at 20 tokens; Kirchenbauer et al. report detection from 25. The 400-token figure people may be half-remembering is Nature's evaluation setting at a 1% false positive rate. An evaluation setting is not a minimum.
  • "Watermarking does not change quality." True for a non-distortionary scheme. Not true for the one in this tutorial, as step 3 measured directly.

This stopped being purely academic recently. EU AI Act Article 50(2), which requires providers of generative systems to mark outputs in a machine-readable format, became applicable on 2 August 2026 - an obligation that gets considerably harder when the model is running on a device you cannot reach. Anthropic announced in August 2026 that Claude models released on or after that same date will carry a watermark it describes as a version of the SynthID-Text approach.

Watermarking marks the text itself. The complementary approach is to record where the text came from, which is the subject of signed provenance logs in Python. The two solve different halves of the same problem, and a system that needs to answer "where did this come from" in court will want both.

Where to go next

If you want to keep going, selfhash is the cheapest next thing and the attack is the most interesting.

  1. Switch the seeding scheme to selfhash. The version you built seeds only from the previous token, so an attacker who learns the mapping for common tokens learns a lot. selfhash folds the candidate token into the seed as well, which resists that. Read WatermarkLogitsProcessor._score_rejection_sampling in transformers and implement it in GreenListWatermarker, then confirm your z-scores still match the library's with seeding_scheme="selfhash".
  2. Map the delta-versus-quality frontier. In a copy of the project, as in step 6, re-run step 4 across delta values from 0.5 to 5.0, recording the z-score from step 5 and the model's perplexity on the generated text. You will get a curve, not a number. Picking a point on it is a product call, and you should make it with the perplexity column in front of you.
  3. Reproduce the watermark-stealing attack. This is the one worth your weekend, and it matters beyond attribution: reliable detection is one practical defence against synthetic text quietly reentering training corpora. Jovanović et al. describe querying a watermarked model repeatedly until you can approximate its green lists without ever holding the key. You are in an unusually good position to try it, because you have both a watermarked model and its ground truth sitting on disk. Recover the lists, then measure two things the paper cannot tell you about your own configuration: how many queries it took, and how far the recovered lists have to drift before your step 5 detector stops believing them. Most attack write-ups report only the first number. The second is the one that decides whether the attack matters to you.

References

Papers

  • Kirchenbauer, J., Geiping, J., Wen, Y., Katz, J., Miers, I., & Goldstein, T. (2023). A Watermark for Large Language Models. ICML 2023. arXiv:2301.10226. https://arxiv.org/abs/2301.10226
  • Dathathri, S., See, A., Ghaisas, S., Huang, P.-S., McAdam, R., Welbl, J., et al. (2024). Scalable watermarking for identifying large language model outputs. Nature, 634, 818-823. https://doi.org/10.1038/s41586-024-08025-4
  • Jovanović, N., Staab, R., & Vechev, M. (2024). Watermark Stealing in Large Language Models. ICML 2024. arXiv:2402.19361. https://arxiv.org/abs/2402.19361

Documentation

Code

Industry and regulation


AI Engineering

Natural Language Processing Nlp

Follow for more technical deep dives on AI/ML systems, production engineering, and building real-world applications:


Get the next article by email

One email when a new piece goes up. No digest, no drip sequence.

One email per new article. Unsubscribe in one click.

Books by Ranjan Kumar

The 7 GenAI Architectures cover

The 7 GenAI Architectures

Building Real-World Agentic AI Systems with LangGraph cover

Building Real-World Agentic AI Systems

The ChatML Handbook cover

The ChatML Handbook

The Chat Templates Handbook cover

The Chat Templates Handbook

Comments