Watermarking › Implementation

Michael BrenndoerferMarch 13, 202616 min read
Language AI HandbookAll chapters
Part 66 of 67Frontier Methods of 2025
Chapter 505Watermarking › Implementation
Reading section 7 of 10 · Implementation · 16 minSection 7 of 10 · 16 min

Book contents

Section 7 of 10 · 16 min read

Watermarking › ImplementationOne section · Sections in this chapter7 / 10 ↓
Reading tools Definitions & inline links

Choose your expertise level to adjust how many terms are explained. Beginners see more tooltips, experts see fewer to maintain reading flow. Hover over underlined terms for instant definitions.

Article links

Make inline references clickable

Let's implement the KGW watermarking scheme from scratch to see exactly how the generation and detection pipelines work. We will use a simplified vocabulary and a toy language model to illustrate the mechanics, then apply it to a realistic scenario.

Setup and Vocabulary

We begin by setting up the vocabulary and hash function for partitioning:

In[5]:
Code
import numpy as np

# Seed for reproducibility
rng = np.random.default_rng(42)

# Simplified vocabulary (actual LLMs have 50k+ tokens)
vocab = [
    "the",
    "cat",
    "sat",
    "on",
    "mat",
    "a",
    "dog",
    "ran",
    "fast",
    "slow",
    "jumped",
    "over",
    "fence",
    "small",
    "large",
    "red",
    "blue",
    "green",
    "happy",
    "sad",
    "sun",
    "moon",
    "star",
    "tree",
    "house",
    "water",
    "fire",
    "earth",
    "wind",
    "rain",
    "snow",
    "cold",
    "warm",
    "hot",
    "good",
    "bad",
    "big",
    "little",
    "old",
    "new",
    "black",
    "white",
    "left",
    "right",
    "up",
    "down",
    "in",
    "out",
    "yes",
    "no",
]
vocab_size = len(vocab)
word_to_id = {w: i for i, w in enumerate(vocab)}
id_to_word = {i: w for w, i in word_to_id.items()}

# Secret watermark key
SECRET_KEY = "language-ai-handbook-watermark-key-2024"
GAMMA = 0.5  # fraction of vocab in green list
DELTA = 2.0  # logit boost for green tokens
Out[6]:
Console
Vocabulary size: 50
Green list fraction (gamma): 0.5
Logit boost (delta): 2.0
Expected green list size: 25 tokens

With a vocabulary of 50 words and a green fraction of 0.5, roughly 25 tokens will be on the green list at any given step. The partition changes at each step based on the previous token, so the same word can be green at one point in a sequence and red at another.

Green List Generation

The key function maps a context token to a green list using a cryptographic hash:

In[7]:
Code
def get_green_list(
    prev_token_id: int, secret_key: str, vocab_size: int, gamma: float
) -> set:
    """
    Derive the green list for the current position using a hash of
    the previous token and the secret key. Returns the set of token IDs
    that are on the green list.
    """
    # Create a deterministic seed from the context and key
    seed_str = f"{secret_key}:{prev_token_id}"
    hash_bytes = hashlib.sha256(seed_str.encode()).digest()
    seed = int.from_bytes(hash_bytes[:4], "big")

    # Use the seed to deterministically shuffle the vocabulary
    local_rng = np.random.default_rng(seed)
    shuffled = local_rng.permutation(vocab_size)

    # The first gamma fraction of the shuffled vocabulary is the green list
    green_size = int(gamma * vocab_size)
    return set(shuffled[:green_size].tolist())
Out[8]:
Console
Green list after 'cat' (first 10): ['a', 'bad', 'cat', 'cold', 'dog', 'earth', 'green', 'house', 'large', 'left']
Green list after 'dog' (first 10): ['a', 'black', 'cat', 'down', 'earth', 'fast', 'fire', 'good', 'green', 'little']
Overlap between the two lists: 15 tokens (60%)

Token 'mat' in green list after 'cat': False
Token 'mat' in green list after 'dog': False

The overlap between green lists for different context tokens is approximately 50%, consistent with random partitioning. The same token can be green after one context word and red after another, which is why the watermark cannot be removed by simply avoiding specific words. An attacker who observes that "mat" is green after "cat" has learned nothing about whether "mat" will be green after "dog" or "tree."

Watermarked Text Generation

The generation function modifies the logits before sampling. The modification is minimal: we add δ\delta to the logit of each green-list token and leave red-list logits unchanged. Sampling then proceeds normally from the modified distribution.

In[9]:
Code
def generate_watermarked(
    prompt_ids: list[int],
    base_logits_fn,
    vocab_size: int,
    secret_key: str,
    gamma: float,
    delta: float,
    n_tokens: int,
    seed: int = 0,
) -> list[int]:
    """
    Generate tokens using the KGW watermarking scheme.
    At each step, boost the logits of green-list tokens by delta.
    """
    gen_rng = np.random.default_rng(seed)
    tokens = list(prompt_ids)

    for _ in range(n_tokens):
        prev_token_id = tokens[-1]

        # Get base logits from the language model
        logits = base_logits_fn(tokens, gen_rng)

        # Get green list for the current context
        green_list = get_green_list(
            prev_token_id, secret_key, vocab_size, gamma
        )

        # Boost green-list logits by delta (soft watermarking)
        for token_id in green_list:
            logits[token_id] += delta

        # Convert logits to probabilities via softmax
        logits_shifted = logits - logits.max()
        probs = np.exp(logits_shifted) / np.exp(logits_shifted).sum()

        # Sample next token
        next_token = gen_rng.choice(vocab_size, p=probs)
        tokens.append(int(next_token))

    return tokens[len(prompt_ids) :]  # Return only generated tokens


def generate_unwatermarked(
    prompt_ids: list[int],
    base_logits_fn,
    vocab_size: int,
    n_tokens: int,
    seed: int = 0,
) -> list[int]:
    """Generate tokens without watermarking, for comparison."""
    gen_rng = np.random.default_rng(seed)
    tokens = list(prompt_ids)

    for _ in range(n_tokens):
        logits = base_logits_fn(tokens, gen_rng)
        logits_shifted = logits - logits.max()
        probs = np.exp(logits_shifted) / np.exp(logits_shifted).sum()
        next_token = gen_rng.choice(vocab_size, p=probs)
        tokens.append(int(next_token))

    return tokens[len(prompt_ids) :]


def dummy_lm_logits(
    token_ids: list[int], rng: np.random.Generator
) -> np.ndarray:
    """
    A simple uniform language model. In practice, this would be the actual
    transformer's output logits. Here we use uniform base logits plus a
    small random perturbation to simulate natural variation.
    """
    base = np.zeros(vocab_size)
    # Small random perturbation to avoid perfectly uniform outputs
    noise = rng.normal(0, 0.5, vocab_size)
    return base + noise
Out[10]:
Console
Watermarked   : new up good in right little mat white star warm in little over old over left hap...
Unwatermarked : right up bad in black old mat left fire rain down old green bad over new sun bad...

Tokens generated: 100

Both sequences look like random strings of words because our dummy language model is nearly uniform. In practice, a real language model would produce coherent sentences whose token choices are heavily constrained by grammar and semantics. The key point is that the watermarked sequence's token choices are systematically biased toward the green list at each position, an invisible pattern that accumulates into a detectable signal.

The Detection Procedure

The detector needs only the candidate text tokens, the secret key, and the parameters γ\gamma and threshold. It recomputes the green list at each position (using the same hash function and key used during generation), counts how many tokens fall on the green list, and runs the z-test.

In[11]:
Code
def detect_watermark(
    token_ids: list[int],
    secret_key: str,
    vocab_size: int,
    gamma: float,
    z_threshold: float = 2.33,  # Corresponds to FPR ~0.01
) -> dict:
    """
    Run the KGW watermark detector on a sequence of token IDs.
    Returns the z-score, p-value, and detection decision.
    """
    from scipy import stats

    T = len(token_ids)
    if T < 2:
        return {
            "z_score": 0.0,
            "p_value": 1.0,
            "is_watermarked": False,
            "n_tokens": T,
        }

    # Count green tokens
    green_count = 0
    for pos in range(1, T):
        prev_token_id = token_ids[pos - 1]
        token_id = token_ids[pos]
        green_list = get_green_list(
            prev_token_id, secret_key, vocab_size, gamma
        )
        if token_id in green_list:
            green_count += 1

    n_tested = T - 1  # First token has no previous context
    expected_green = gamma * n_tested
    std_green = np.sqrt(n_tested * gamma * (1 - gamma))

    z_score = (green_count - expected_green) / std_green
    p_value = 1 - stats.norm.cdf(z_score)
    is_watermarked = z_score > z_threshold

    return {
        "z_score": z_score,
        "green_count": green_count,
        "expected_green": expected_green,
        "p_value": p_value,
        "is_watermarked": is_watermarked,
        "n_tokens": n_tested,
    }
Out[12]:
Console
Detection results (z-threshold = 2.33, FPR ~0.01):

Watermarked text:
  Green tokens : 85 / 101 (expected 50.5)
  Z-score      : 6.866
  p-value      : 0.000000
  Decision     : WATERMARKED

Unwatermarked text:
  Green tokens : 50 / 101 (expected 50.5)
  Z-score      : -0.100
  p-value      : 0.539631
  Decision     : NOT WATERMARKED

The watermarked text shows a z-score substantially above the threshold, while the unwatermarked text's z-score is close to zero, consistent with the null hypothesis. The p-value for the watermarked text is very small, indicating strong statistical evidence for the presence of the watermark. Notice that the detection function has no access to the generation function or the original model: it operates only on the token sequence and the key. This is what makes the scheme practical for real-world deployment, where the detector may run on a different server from the generator.

Visualizing Detection Power vs. Text Length

The detection power increases with the number of tokens. Let's visualize how the z-score and detection decision evolve as more text is processed:

In[13]:
Code
def simulate_detection_power(
    n_tokens_range: list[int],
    n_trials: int,
    vocab_size: int,
    secret_key: str,
    gamma: float,
    delta: float,
    z_threshold: float,
) -> dict:
    """
    For each text length, generate n_trials watermarked and unwatermarked
    texts and compute TPR and FPR.
    """
    tpr_list = []
    fpr_list = []
    z_wm_means = []
    z_uw_means = []

    prompt = [0]  # Single starting token (token ID 0)

    for n_tokens in n_tokens_range:
        wm_detections = 0
        uw_detections = 0
        z_wm_vals = []
        z_uw_vals = []

        for trial in range(n_trials):
            wm_gen = generate_watermarked(
                prompt,
                dummy_lm_logits,
                vocab_size,
                secret_key,
                gamma,
                delta,
                n_tokens,
                seed=trial * 100,
            )
            uw_gen = generate_unwatermarked(
                prompt, dummy_lm_logits, vocab_size, n_tokens, seed=trial * 100
            )

            wm_result = detect_watermark(
                prompt + wm_gen, secret_key, vocab_size, gamma, z_threshold
            )
            uw_result = detect_watermark(
                prompt + uw_gen, secret_key, vocab_size, gamma, z_threshold
            )

            wm_detections += int(wm_result["is_watermarked"])
            uw_detections += int(uw_result["is_watermarked"])
            z_wm_vals.append(wm_result["z_score"])
            z_uw_vals.append(uw_result["z_score"])

        tpr_list.append(wm_detections / n_trials)
        fpr_list.append(uw_detections / n_trials)
        z_wm_means.append(np.mean(z_wm_vals))
        z_uw_means.append(np.mean(z_uw_vals))

    return {
        "n_tokens_range": n_tokens_range,
        "tpr": tpr_list,
        "fpr": fpr_list,
        "z_wm_means": z_wm_means,
        "z_uw_means": z_uw_means,
    }
Out[15]:
Console
Detection power vs. text length:
  Tokens |      TPR |      FPR |    E[z|WM] |   E[z|no WM]
----------------------------------------------------------
      10 |    0.610 |    0.010 |       2.33 |         0.07
      20 |    0.900 |    0.005 |       3.33 |         0.06
      30 |    1.000 |    0.010 |       4.15 |         0.10
      50 |    1.000 |    0.005 |       5.40 |         0.10
      75 |    1.000 |    0.000 |       6.63 |         0.10
     100 |    1.000 |    0.010 |       7.62 |         0.06
     150 |    1.000 |    0.010 |       9.34 |         0.03
     200 |    1.000 |    0.020 |      10.79 |         0.03

The table shows how TPR grows from near-chance at 10 tokens toward reliable detection at 100 or more tokens, while FPR stays close to the theoretical value of 0.01. This empirical confirmation that the test is well-calibrated under the null (FPR tracks 0.01 across all lengths) is important: it means the hypothesis testing framework's assumptions are satisfied in this implementation.

Visualization: Detection Power and Z-Score Distributions

Out[16]:
Visualization
Line chart showing watermarked TPR rising toward 1.0 with text length while unwatermarked FPR remains near the 0.01 target.
Watermark detection rates as a function of token count (200 trials per length). TPR (solid) rises steeply between 20 and 100 tokens, approaching reliable detection above 100 tokens, while FPR (dashed) stays near the nominal 0.01 threshold throughout.
Line chart showing the watermarked mean z-score rising above the 2.33 threshold with text length while the unwatermarked mean stays near zero.
Mean z-score for watermarked (solid) and unwatermarked (dashed) texts as a function of token count. The watermarked z-score grows steadily with sequence length as evidence accumulates, while the unwatermarked score stays near zero, confirming the test's calibration.

The TPR curve rises steeply between 20 and 100 tokens, reaching reliable detection in the 100 to 150 token range for this toy model. The FPR line stays near the 0.01 theoretical value throughout, confirming that the hypothesis test is well-calibrated. The z-score plot tells the same story from a different angle: the watermarked mean z-score grows proportionally to T\sqrt{T}, while the unwatermarked mean stays near zero as expected.

Simulating a Paraphrase Attack

To understand robustness, let's simulate an attacker who replaces a random fraction of tokens. In practice, a paraphrase model would select semantically appropriate replacements, but random substitution gives us a controlled experiment that quantifies the signal decay as a function of substitution rate.

In[17]:
Code
def paraphrase_attack(
    token_ids: list[int],
    substitution_rate: float,
    vocab_size: int,
    seed: int = 0,
) -> list[int]:
    """
    Simulate a paraphrase attack by randomly substituting a fraction of tokens.
    In a real attack, a paraphrase model would be used to substitute tokens
    with semantically similar alternatives. Here we use random substitution
    as a worst-case upper bound on attack effectiveness.
    """
    attack_rng = np.random.default_rng(seed)
    attacked = list(token_ids)
    for i in range(len(attacked)):
        if attack_rng.random() < substitution_rate:
            attacked[i] = int(attack_rng.integers(0, vocab_size))
    return attacked
Out[19]:
Console
Paraphrase attack robustness (100 tokens, 300 trials):
   Substitution Rate |      TPR
----------------------------------
                0.0% |    1.000
               10.0% |    1.000
               20.0% |    0.993
               30.0% |    0.940
               40.0% |    0.710
               50.0% |    0.393
Out[20]:
Visualization
Line chart showing TPR decreasing from near 1.0 to about 0.4 as substitution rate increases from 0% to 50%, with the nominal 0.01 FPR baseline shown below.
TPR decay under random token substitution attack for 100-token watermarked texts (300 trials per rate). As substitution rate increases, the green token signal is progressively diluted and TPR falls from near-perfect detection to about 0.4 at 50% substitution. The nominal FPR threshold (dotted line) shows that detection remains above chance but becomes substantially less reliable. Real paraphrase attacks would be somewhat less effective at low rates because synonym substitutions often preserve green-list membership.

Even with 30% of tokens randomly substituted, detection often remains reliable. At 50% substitution, TPR falls sharply to around 0.4: still well above the false positive baseline, but no longer reliable enough for confident attribution. A real paraphrase attack using a language model would be less effective at low substitution rates because synonym substitutions often preserve green-list membership by chance. Two synonyms for the same concept tend to appear in similar contexts, and the hash function assigns them the same green list for any given preceding token. However, at high substitution rates, even semantically aware paraphrasers become effective.

Comparing Watermarked and Unwatermarked Token Distributions

Out[22]:
Visualization
Overlapping density histograms with unwatermarked green-token rates centered near 0.5 and watermarked rates concentrated near 0.85, plus a vertical expected-rate reference at 0.5.
Distribution of green token rates across 500 generated texts (50 tokens each). Watermarked texts (blue) show a higher green token rate centered above 0.5 due to the logit boost, while unwatermarked texts (orange) center near the expected rate of 0.5. The separation between the two distributions is the detectable signal. A larger delta or more tokens per document would shift the blue distribution further right, increasing detection power.

The separation between the two distributions is the signal that the z-test exploits. The unwatermarked distribution is centered near γ=0.5\gamma = 0.5, exactly as the null hypothesis predicts. The watermarked distribution is shifted to the right because the logit boost increases the probability of choosing green tokens. A higher δ\delta would shift the watermarked distribution further right, increasing detection power at the cost of more noticeable distortion in text quality.

Key Parameters

The main parameters of the KGW watermarking scheme are:

  • γ\gamma (gamma): The fraction of the vocabulary assigned to the green list, typically 0.5. Smaller values make detection harder because the expected green count under both hypotheses is lower, reducing the signal-to-noise ratio. They may also cause more quality degradation at positions where the model's best token is always on the smaller green list.
  • δ\delta (delta): The additive logit boost for green tokens, typically 2.0. Higher values increase detection power because each token provides more evidence, but they distort the output distribution more noticeably. The relationship between δ\delta and perplexity increase is roughly log-linear for moderate values.
  • z_thresholdz\_\text{threshold}: The detection threshold corresponding to a chosen FPR. Use 2.33 for FPR 0.01, 3.09 for FPR 0.001, and 5.5 for Bonferroni-corrected FPR at one million tests. Higher thresholds reduce false positives but require more tokens for reliable detection.
  • Context window: The number of previous tokens used to hash the green list, default 1. Larger windows increase robustness against targeted substitution but complicate detection after text edits and increase computational cost.