Source code for tropt.optimizer.gcgplus_optimizer

from __future__ import annotations
import logging
import math
import random
from typing import Callable, List, Literal, Optional, Tuple, Union

import torch
from jaxtyping import Float, Int
from torch import Tensor

from tropt.common import (
    DEFAULT_INIT_TRIGGER,
    Targets,
    TextTemplates,
)
from tropt.loss import BaseLoss
from tropt.model import (
    BaseModel,
    GradientTokenAccessMixin,
    LogitsTokenAccessMixin,
    LossTextAccessMixin,
    LossTokenAccessMixin,
    TokenAccessMixin,
)
from tropt.optimizer import BaseOptimizer, OptimizerResult
from tropt.optimizer.utils.buffer import TriggerBuffer
from tropt.optimizer.utils.retokenization import retokenize_filtering
from tropt.optimizer.utils.running_best import RunningBest
from tropt.optimizer.utils.token_constraints import TokenConstraints
from tropt.optimizer.utils.token_initializers import get_printable_random_trigger
from tropt.tracker import BaseTracker

logger = logging.getLogger(__name__)


[docs] class GCGPlusOptimizer(BaseOptimizer): """Flexible GCG implementation, supporting tricks from GCG, QCG, GASLITE, and UAT. Two-stage design: 1. Candidate selection (on proxy model) — gradient-based, random, or focused. 2. Candidate evaluation (on target model) — via text or token access. References: - GCG: https://arxiv.org/abs/2307.15043 - QCG: https://arxiv.org/abs/2402.12329 - PAL: https://arxiv.org/abs/2402.09674 - GASLITE: https://arxiv.org/abs/2412.20953 - UAT: https://arxiv.org/abs/1908.07125 """ model_requirements = (LossTextAccessMixin,) def __init__( self, model: BaseModel, loss: BaseLoss, tracker: Optional[BaseTracker] = None, seed: Optional[int] = None, # Proxy model for candidate selection (defaults to model): proxy_model: Optional[BaseModel] = None, # Differentiable loss for proxy gradient computation (defaults to loss): proxy_loss: Optional[BaseLoss] = None, # Candidate selection strategy: candidate_selection: Literal["gradient", "random", "focused"] = "gradient", # Core GCG params: num_steps: int = 500, n_candidates: int = 512, sample_topk: int = 256, token_constraints: TokenConstraints = TokenConstraints(), use_retokenize: bool = True, # Later tricks: sample_n_replace: Union[int, Tuple[int, int]] = (1, 1), candidate_oversample_factor: float = 1.1, momentum: float = 0.0, skip_visited: bool = False, # Trigger buffer size: buffer_size: Optional[int] = None, n_grad_avg: int = 1, # Per-step batch sampling: template_batch_size: Optional[int] = None, template_batch_sampler: Optional[Callable[[TextTemplates, Optional[Targets]], List[int]]] = None, # Token prior (readability): token_prior_weight: float = 0.0, sample_temperature: Optional[float] = None, ): """ Args: proxy_model: Model used for candidate selection (gradients/tokenizer). If None, defaults to `model` (self-proxy / white-box). proxy_loss: Loss function for proxy gradient computation. Required when the main loss is non-differentiable and candidate_selection="gradient". Defaults to `loss`. candidate_selection: "gradient" uses gradient-ranked top-k sampling; "random" uses uniform random token sampling; "focused" probes all positions with target model loss then focuses on the best one (from QCG paper). sample_n_replace: (start, end) number of token positions to replace per candidate. Linearly interpolated over optimization steps; from PAL paper. Defaults to (1, 1) for single-token flips only. candidate_oversample_factor: Craft n_candidates * factor candidates (>1.0), then truncate after retokenization filtering, to fill the required number of candidates. From PAL paper. Defaults to 1.1 (10% oversampling). momentum: Gradient momentum coefficient. When > 0, enables momentum: m = mu*m + (1-mu)*grad for candidate ranking instead of raw gradient. Defaults to 0.0 (no momentum). Reference: https://arxiv.org/abs/2405.01229 . skip_visited: If True, never re-select a previously accepted trigger (PAL-style); avoids cycling/stalling. Defaults to False. buffer_size: If set, maintain a buffer of the best triggers seen (from QCG paper). Each step starts from the best buffer entry and updates it with improved candidates. Defaults to None (no buffer). n_grad_avg: Number of trigger perturbations to average gradients over. When > 1, flips a random position per copy (from ARCA/GASLITE papers). Defaults to 1 (no averaging, like GCG). template_batch_size: If set, sample this many templates (and their targets) per optimization step instead of using all templates simultaneously. Useful for large template sets. Reference: https://arxiv.org/abs/1908.07125 . Defaults to None (use all templates). template_batch_sampler: If set, called as `template_batch_sampler(templates, targets)` each step to pick the template indices for that step (e.g., stratified batches); defaults to uniform sampling of `template_batch_size` templates. token_prior_weight: weight of the readability prior (under gradient selection only). When non zero, in each step, the proxy's log p(v | prefix) at every trigger position (one forward on the current trigger) is added to the loss. Inspired by AutoDAN (https://arxiv.org/abs/2310.15140). Requires a proxy with logits access and a trigger slice that starts after position 0 (i.e., `use_prefix_cache=False`). Defaults to 0.0 (off). sample_temperature: If set, sample tokens from the top-k by softmax(score / T) instead of uniformly (under gradient selection only). Defaults to None (uniform). """ assert template_batch_sampler is None or template_batch_size is None, ( "Pass either `template_batch_sampler` or `template_batch_size`, not both." ) super().__init__(model, loss=loss, tracker=tracker, seed=seed) # Proxy model setup self.proxy_model = model if proxy_model is None else proxy_model assert isinstance(self.proxy_model, TokenAccessMixin), ( "proxy_model must support TokenAccessMixin (tokenizer access)" ) self.proxy_loss = proxy_loss if proxy_loss is not None else self.loss_func if candidate_selection == "gradient": assert isinstance(self.proxy_model, GradientTokenAccessMixin), ( "candidate_selection='gradient' requires proxy_model with GradientTokenAccessMixin" ) if not self.proxy_loss.is_differentiable: raise ValueError( f"candidate_selection='gradient' requires a differentiable proxy_loss, " f"but {type(self.proxy_loss).__name__}.is_differentiable=False. " f"Pass a differentiable proxy_loss (e.g. PrefillCELoss())." ) assert candidate_oversample_factor >= 1.0, "candidate_oversample_factor must be >= 1.0" if token_prior_weight > 0 or sample_temperature is not None: assert candidate_selection == "gradient", ( "`token_prior_weight` / `sample_temperature` require candidate_selection='gradient'." ) if token_prior_weight > 0: assert isinstance(self.proxy_model, LogitsTokenAccessMixin), ( "`token_prior_weight` requires a proxy_model with LogitsTokenAccessMixin." ) # Prefer token-level target evaluation when proxy and target share the same tokenizer # (otherwise, if tokenizer are not shared, or we don't have access to the target model's tokenzier, we simply use text-level loss compuation) use_token_input_for_loss = (model.tokenizer == self.proxy_model.tokenizer) and isinstance(model, LossTokenAccessMixin) # Normalize sample_n_replace to tuple if isinstance(sample_n_replace, int): sample_n_replace = (sample_n_replace, sample_n_replace) # Save params self.candidate_selection = candidate_selection self.num_steps = num_steps self.n_candidates = n_candidates self.sample_topk = sample_topk self.sample_n_replace = sample_n_replace self.token_constraints = token_constraints self.use_retokenize = use_retokenize self.candidate_oversample_factor = candidate_oversample_factor self.momentum = momentum self.skip_visited = skip_visited self.use_token_input_for_loss = use_token_input_for_loss self.buffer_size = buffer_size self.n_grad_avg = n_grad_avg self.template_batch_size = template_batch_size if template_batch_sampler is None and template_batch_size is not None: # default: uniform random batch template_batch_sampler = lambda templates, targets: random.sample(range(len(templates)), template_batch_size) # noqa: E731 self.template_batch_sampler = template_batch_sampler self.token_prior_weight = token_prior_weight self.sample_temperature = sample_temperature
[docs] def optimize_trigger( self, templates: TextTemplates, initial_trigger: Optional[str] = DEFAULT_INIT_TRIGGER, targets: Optional[Targets] = None, ) -> OptimizerResult: # --- Initialization --- proxy_model = self.proxy_model proxy_tokenizer = proxy_model.tokenizer target_model = self.model # Batch sampling setup use_batch_sampling = self.template_batch_sampler is not None and ( self.template_batch_size is None or self.template_batch_size < len(templates) ) proxy_model.set_inputs_from_tokens(templates=templates, targets=targets) if not self.use_token_input_for_loss: target_model.set_inputs_from_texts(templates=templates, targets=targets) else: target_model.set_inputs_from_tokens(templates=templates, targets=targets) trigger_ids: Int[Tensor, "trigger_seq_len"] = proxy_tokenizer.encode_trigger(initial_trigger).to(proxy_model.device) vocab_size = proxy_model.vocab_size blacklist_ids = self.token_constraints.get_blacklist_ids(proxy_tokenizer, vocab_size) valid_token_ids = self.token_constraints.get_whitelist_ids(proxy_tokenizer, vocab_size, proxy_model.device, return_tensor=True) best = RunningBest() momentum_buffer: Optional[Tensor] = None visited: set[str] = set() # Buffer initialization buffer: Optional[TriggerBuffer] = None if self.buffer_size is not None: buffer = self._init_buffer(trigger_ids) # Number of candidates to generate (oversample if retokenize is on) n_candidates_oversampled = self.n_candidates if self.use_retokenize and self.candidate_oversample_factor > 1.0: n_candidates_oversampled = math.ceil(self.n_candidates * self.candidate_oversample_factor) # Initial loss (on full set or first batch) current_loss = self._evaluate_candidates( trigger_ids.unsqueeze(0) ).item() trigger_str = proxy_tokenizer.decode_trigger(trigger_ids) self.log(loss=current_loss, trigger_str=trigger_str) if self.skip_visited: visited.add(trigger_str) n_replace_start, n_replace_end = self.sample_n_replace for step_i in self.track_steps(range(self.num_steps)): # --- batch sampling: re-set model inputs with a random subset --- if use_batch_sampling: batch_indices = self.template_batch_sampler(templates, targets) batch_templates = [templates[i] for i in batch_indices] batch_targets = ( targets.select_indices(batch_indices) if targets is not None else None ) proxy_model.set_inputs_from_tokens( templates=batch_templates, targets=batch_targets, ) if not self.use_token_input_for_loss: target_model.set_inputs_from_texts( templates=batch_templates, targets=batch_targets, ) else: target_model.set_inputs_from_tokens( templates=batch_templates, targets=batch_targets, ) # Linearly interpolate sample_n_replace over steps cur_n_replace = round( n_replace_start + (n_replace_end - n_replace_start) * step_i / max(self.num_steps - 1, 1) ) # Buffer mode: start each step from the best buffer entry if buffer is not None: trigger_ids = buffer.get_best_trigger() # === Stage 1: Candidate Selection (on proxy) === token_log_probs: Optional[Tensor] = None if self.candidate_selection == "gradient": grad_triggers = self._get_grad_trigger_variations( trigger_ids, valid_token_ids ) trigger_grad = proxy_model.compute_grad_from_tokens( candidate_trigger_ids=grad_triggers, loss_func=self.proxy_loss, normalize_grads=True, ).mean(dim=0) # average over n_grad_avg variations # Apply momentum if self.momentum > 0: if momentum_buffer is None: momentum_buffer = trigger_grad.clone() else: momentum_buffer = ( self.momentum * momentum_buffer + (1 - self.momentum) * trigger_grad ) trigger_grad = momentum_buffer if self.token_prior_weight > 0: # log p(v | prefix) at every trigger position, from one forward on the current trigger token_log_probs = proxy_model.compute_logits_from_tokens( trigger_ids.unsqueeze(0), return_trigger_logits_only=True, )[0].float().log_softmax(dim=-1) # (trigger_seq_len, vocab_size) candidate_trigger_ids = self._sample_ids_from_grad( trigger_ids=trigger_ids, trigger_grad=trigger_grad, token_log_probs=token_log_probs, blacklist_ids=blacklist_ids, n_candidates=n_candidates_oversampled, n_replace=cur_n_replace, ) elif self.candidate_selection == "focused": candidate_trigger_ids = self._sample_focused_candidates( trigger_ids=trigger_ids, valid_token_ids=valid_token_ids, n_candidates=n_candidates_oversampled, ) else: # "random" candidate_trigger_ids = self._sample_random_candidates( trigger_ids=trigger_ids, valid_token_ids=valid_token_ids, n_candidates=n_candidates_oversampled, n_replace=cur_n_replace, ) # === Retokenization filtering === if self.use_retokenize: candidate_trigger_ids = retokenize_filtering( candidate_trigger_ids, proxy_tokenizer ) # Drop already-accepted triggers (PAL-style) before truncating if self.skip_visited and len(candidate_trigger_ids) > 0: cand_strs = proxy_tokenizer.decode_triggers(candidate_trigger_ids) keep = torch.tensor( [s not in visited for s in cand_strs], device=candidate_trigger_ids.device, ) candidate_trigger_ids = candidate_trigger_ids[keep] # Truncate to n_candidates (after oversample + filter) candidate_trigger_ids = candidate_trigger_ids[: self.n_candidates] if len(candidate_trigger_ids) == 0: logger.warning("All candidates filtered out, skipping step.") continue # === Stage 2: Candidate Evaluation (on target) === losses = self._evaluate_candidates(candidate_trigger_ids) if token_log_probs is not None: positions = torch.arange(candidate_trigger_ids.shape[1], device=candidate_trigger_ids.device) cand_log_probs = token_log_probs[positions, candidate_trigger_ids].sum(dim=-1) # (n_candidates,) losses = losses - self.token_prior_weight * cand_log_probs.to(losses) # === Update best / buffer === if buffer is not None: # Buffer mode: update buffer with all evaluated candidates for idx in range(len(candidate_trigger_ids)): buffer.add_if_better( candidate_trigger_ids[idx], losses[idx].item() ) # Track overall best from buffer for logging current_loss = buffer.get_lowest_loss() trigger_ids = buffer.get_best_trigger() trigger_str = proxy_tokenizer.decode_trigger(trigger_ids) else: current_loss = losses.min().item() trigger_ids = candidate_trigger_ids[losses.argmin()] trigger_str = proxy_tokenizer.decode_trigger(trigger_ids) best.update(loss=current_loss, trigger_ids=trigger_ids, trigger_str=trigger_str) self.log(loss=current_loss, trigger_str=trigger_str) if self.skip_visited: visited.add(trigger_str) # --- Finalize --- result = best.to_result() return result
# ------------------------------------------------------------------ # Initialization # ------------------------------------------------------------------ def _init_buffer( self, initial_trigger_ids: Int[Tensor, "trigger_seq_len"], ) -> TriggerBuffer: """Initialize the buffer with the initial trigger and printable-random variants. """ assert self.buffer_size is not None trigger_seq_len = initial_trigger_ids.shape[0] device = initial_trigger_ids.device tokenizer = self.proxy_model.tokenizer blacklist_ids = self.token_constraints.get_blacklist_ids(tokenizer, self.proxy_model.vocab_size) triggers_list: list[Tensor] = [initial_trigger_ids] for _ in range(self.buffer_size - 1): # Oversample length (reencode can drift shorter), then trim to trigger_seq_len. rand_str = get_printable_random_trigger( trigger_len=2 * trigger_seq_len, tokenizer=tokenizer, blacklist_ids=blacklist_ids, ) ids = tokenizer.encode_trigger(rand_str).to(device)[:trigger_seq_len] triggers_list.append(ids) all_triggers = torch.stack(triggers_list, dim=0) all_losses = self._evaluate_candidates(all_triggers) return TriggerBuffer( triggers=list(all_triggers), losses=[all_losses[i].item() for i in range(self.buffer_size)], ) # ------------------------------------------------------------------ # Stage 1: Candidate Selection # ------------------------------------------------------------------ def _sample_ids_from_grad( self, trigger_ids: Int[Tensor, "trigger_seq_len"], trigger_grad: Float[Tensor, "trigger_seq_len vocab_size"], blacklist_ids: list, n_candidates: int, n_replace: int = 1, token_log_probs: Optional[Float[Tensor, "trigger_seq_len vocab_size"]] = None, ) -> Int[Tensor, "n_candidates trigger_seq_len"]: """Sample candidate token sequences via GCG-style random multi-position replacement.""" trigger_seq_len = trigger_grad.shape[0] device = trigger_grad.device n_replace = min(n_replace, trigger_seq_len) # Top-k candidates per position scores = -trigger_grad # (new tensor; avoids mutating the momentum buffer) if token_log_probs is not None: assert token_log_probs.shape == scores.shape, ( f"Token log-probs {tuple(token_log_probs.shape)} do not match the gradient {tuple(scores.shape)}." ) scores = scores + self.token_prior_weight * token_log_probs.to(scores) scores[:, blacklist_ids] = float("-inf") topk = scores.topk(self.sample_topk, dim=-1) topk_ids = topk.indices candidate_trigger_ids = trigger_ids.repeat(n_candidates, 1).clone() # Random positions to flip sampled_ids_pos = torch.rand( n_candidates, trigger_seq_len, device=device ).argsort(dim=-1)[..., :n_replace] # Select relevant top-k lists and sample one token from each relevant_topk_lists = topk_ids[sampled_ids_pos] if self.sample_temperature is None: rand_k_indices = torch.randint( 0, self.sample_topk, (n_candidates, n_replace, 1), device=device, ) else: # softmax(score / T) over each position's top-k probs = (topk.values[sampled_ids_pos] / self.sample_temperature).softmax(dim=-1) # (n_cand, n_replace, k) rand_k_indices = torch.multinomial(probs.view(-1, self.sample_topk), 1).view(n_candidates, n_replace, 1) sampled_ids_val = torch.gather( input=relevant_topk_lists, dim=-1, index=rand_k_indices, ).squeeze(-1) candidate_trigger_ids = candidate_trigger_ids.scatter_( dim=-1, index=sampled_ids_pos, src=sampled_ids_val, ) return candidate_trigger_ids def _get_grad_trigger_variations( self, trigger_ids: Int[Tensor, "trigger_seq_len"], valid_token_ids: Int[Tensor, "n_valid"], ) -> Int[Tensor, "n_grad_avg trigger_seq_len"]: """Create trigger variations for gradient averaging (GASLITE-style). With n_grad_avg == 1, returns the trigger as-is. With n_grad_avg > 1, flips a random position with a random valid token per copy (keeping the first copy intact). """ if self.n_grad_avg <= 1: return trigger_ids.unsqueeze(0) device = trigger_ids.device trigger_seq_len = trigger_ids.shape[0] grad_triggers = trigger_ids.repeat(self.n_grad_avg, 1).clone() # Keep first copy intact, perturb the rest for idx in range(1, self.n_grad_avg): pos = torch.randint(0, trigger_seq_len, (1,), device=device).item() tok = valid_token_ids[ torch.randint(0, len(valid_token_ids), (1,), device=device) ].item() assert isinstance(pos, int) and isinstance(tok, int) grad_triggers[idx, pos] = tok return grad_triggers def _sample_random_candidates( self, trigger_ids: Int[Tensor, "trigger_seq_len"], valid_token_ids: Int[Tensor, "n_valid"], n_candidates: int, n_replace: int = 1, ) -> Int[Tensor, "n_candidates trigger_seq_len"]: """Sample candidates by randomly replacing tokens (RAL-style).""" trigger_seq_len = trigger_ids.shape[0] device = trigger_ids.device n_replace = min(n_replace, trigger_seq_len) candidate_trigger_ids = trigger_ids.repeat(n_candidates, 1).clone() # Random positions to flip sampled_ids_pos = torch.rand( n_candidates, trigger_seq_len, device=device ).argsort(dim=-1)[..., :n_replace] # Sample uniformly from valid tokens rand_indices = torch.randint( 0, len(valid_token_ids), (n_candidates, n_replace), device=device ) random_tokens = valid_token_ids[rand_indices] candidate_trigger_ids = candidate_trigger_ids.scatter_( dim=-1, index=sampled_ids_pos, src=random_tokens, ) return candidate_trigger_ids def _sample_focused_candidates( self, trigger_ids: Int[Tensor, "trigger_seq_len"], valid_token_ids: Int[Tensor, "n_valid"], n_candidates: int, ) -> Int[Tensor, "n_candidates trigger_seq_len"]: """Focused position sampling (from QCG paper). Phase 1: Probe each position with one random token replacement, evaluate all probes on the target (via _evaluate_candidates) to find the most promising position. Phase 2: Generate n_candidates candidates at the best position. """ trigger_seq_len = trigger_ids.shape[0] device = trigger_ids.device # Phase 1: Probe each position with one random token probe_candidates = trigger_ids.repeat(trigger_seq_len, 1).clone() probe_tokens = valid_token_ids[ torch.randint(0, len(valid_token_ids), (trigger_seq_len,), device=device) ] # Replace position j in candidate j probe_candidates[ torch.arange(trigger_seq_len, device=device), torch.arange(trigger_seq_len, device=device), ] = probe_tokens probe_losses = self._evaluate_candidates(probe_candidates) best_pos = probe_losses.argmin().item() assert isinstance(best_pos, int) # Phase 2: Generate candidates at best position only candidates = trigger_ids.repeat(n_candidates, 1).clone() random_tokens = valid_token_ids[ torch.randint(0, len(valid_token_ids), (n_candidates,), device=device) ] candidates[:, best_pos] = random_tokens return candidates # ------------------------------------------------------------------ # Stage 2: Candidate Evaluation # ------------------------------------------------------------------ def _evaluate_candidates( self, candidate_trigger_ids: Int[Tensor, "n_candidates trigger_seq_len"], ) -> Float[Tensor, "n_candidates"]: """Evaluate candidates on the target model. Returns per-candidate loss. When use_token_input_for_loss is False, decodes candidates via proxy_model.tokenizer. """ if self.use_token_input_for_loss: losses = self.model.compute_loss_from_tokens( candidate_trigger_ids, loss_func=self.loss_func ) else: candidate_strs = self.proxy_model.tokenizer.decode_triggers(candidate_trigger_ids) losses = self.model.compute_loss_from_texts( candidate_strs, loss_func=self.loss_func ) # Reduce to (n_candidates,) if needed if losses.dim() > 1: losses = losses.mean(dim=0) return losses