Source code for interpretune.extensions.debug_generation

from typing import List, Any, Dict, cast
from dataclasses import dataclass, field
from copy import deepcopy

import torch
from datasets import load_dataset, Dataset
import numpy as np
from torch.nn import CrossEntropyLoss

from interpretune.config import ITSerializableCfg
from interpretune.utils import rank_zero_warn, sanitize_input_name, DEFAULT_DECODE_KWARGS
from transformers.utils.generic import ModelOutput
from transformers.generation.utils import GenerateDecoderOnlyOutput
from interpretune.protocol import ITModuleGenDebuggable


[docs] @dataclass(kw_only=True) class DebugLMConfig(ITSerializableCfg): enabled: bool = False debug_raw_preds: np.ndarray | None = None debug_raw_labels: np.ndarray | None = None debug_raw_sequences: list[str] | None = None raw_debug_sequences: list = field(default_factory=list) def __post_init__(self) -> None: if len(self.raw_debug_sequences) == 0 and self.enabled: self.raw_debug_sequences = [ "What is the color of a banana?", "List the first 5 letters in the alphabet.", "How many days in a week?", "How old is Barack Obama?", ]
[docs] class DebugGeneration: """Give user-provided callbacks with the ability to connect to another user-provided callback. This resolution logic is provided in order to avoid callback-dependent trainer attributes (e.g. trainer.finetuningscheduler_callback) """ # TODO: note availability of HF tokenizer methods are assumed for the moment, need to add to contract # TODO: may make sense to add some additional debugging methods that parse and analyze all of the generated outputs # including `output_attentions` and `output_hidden_states` etc. # TODO: Intelligently assign/normalize to appropriate HF ModelOutput subclass based on model type # (see https://bit.ly/hf_model_output_types) # Default HF Generation output dataclass and attributes taken from HF's # GenerateDecoderOnlyOutput dataclass. This centralizes the attribute list # and keeps the normalization logic focused on HF-recognized attributes. DEFAULT_OUTPUT_DATACLS = GenerateDecoderOnlyOutput # Derive standard output attributes from HF dataclass DEFAULT_OUTPUT_ATTRS = tuple(list(DEFAULT_OUTPUT_DATACLS.__annotations__.keys())) DEFAULT_MODEL_CONFIG_ATTRS = ("cfg", "config") phandle: ITModuleGenDebuggable | None def __init__( self, ) -> None: """Arguments.""" super().__init__() self.phandle = None def connect(self, obj_ref: ITModuleGenDebuggable) -> None: self.phandle = obj_ref def _check_phandle(self) -> ITModuleGenDebuggable: """Helper: raise a RuntimeError if phandle isn't connected. This centralizes the check so all call sites have consistent behavior. """ if self.phandle is None: raise RuntimeError("Extension not connected to module - call connect() first") # Help static type checkers: cast to the protocol we've defined assert isinstance(self.phandle, ITModuleGenDebuggable) return cast(ITModuleGenDebuggable, self.phandle)
[docs] def debug_sequences(self, sequences: List | str | None = None) -> List: """_summary_ Args: sequences (List | None, optional): _description_. Defaults to None. Returns: List: _description_ Usage: ```python # one can use this method to probe non-chat fine-tuned models (just the raw sequences, no SYS # or INST metadata) self.debug_lm.debug_generate_batch(self.debug_lm.debug_sequences('My single custom sequence'), gen_config_override={"max_new_tokens": 25}) ``` """ if sequences is None: ph = self._check_phandle() sequences = ph.it_cfg.debug_lm_cfg.raw_debug_sequences or [] if not isinstance(sequences, list): sequences = [sequences] return [f"{ex.strip()}" for ex in sequences]
[docs] def chat_debug_sequences(self, sequences: List | None = None, format: str | None = None) -> List: """Render debug sequences through the configured chat template. Args: sequences (List | None, optional): Debug prompts to render; defaults to the extension's default sanity-check questions when None. format (str | None, optional): Model-variant prompt-config pattern (e.g. ``"llama3-chat"``). Returns: List: Chat-formatted sequences ready for ``debug_generate_batch``/``debug_generate_serial``. Usage:: # for example, using the llama3 chat format, you want to have input tokenized with sys and inst metadata # to do so with some reasonable default questions as a sanity check and in batch mode: self.debug_lm.debug_generate_batch(self.debug_lm.chat_debug_sequences() # to narrow the problem space, using serial inference (non-batch mode) for a list of strings can be useful self.debug_lm.debug_generate_serial(self.debug_lm.chat_debug_sequences()) # to override the defaults (both questions and current `max_new_tokens` config) # you can also specify a specific model variant pattern for a given prompt config, e.g. # `format="llama3-chat"` self.debug_lm.debug_generate_batch(self.debug_lm.chat_debug_sequences(format='llama3-chat', sequences=[ 'What is the color of a cloudless sky?', 'How many days are in a year?']), gen_config_override={"max_new_tokens": 25}) """ try: ph = self._check_phandle() if sequences is None: sequences = ph.it_cfg.debug_lm_cfg.raw_debug_sequences if format is None: format = ph.datamodule.itdm_cfg.cust_tokenization_pattern # type: ignore[attr-defined] # protocol provides datamodule assert isinstance(sequences, list) return [ph.datamodule.itdm_cfg.prompt_cfg.model_chat_template_fn(ex, format) for ex in sequences] # type: ignore[attr-defined] # protocol provides datamodule except Exception as e: rank_zero_warn( f"Failed to generate chat debug sequences. Exception: {e}. " "Returning the stripped sequences but without the corresponding chat format metadata." ) sequences = sequences or [] # sequences could still be None at Exception return [f"{ex.strip()}" for ex in sequences]
[docs] def collect_prompt_debug_info( self, raw_sequences: List | str, *, rendered_sequences: List | str | None = None, add_special_tokens: bool = False, ) -> List[Dict[str, Any]]: """Collect prompt text and tokenization metadata for debugging generation paths.""" ph = self._check_phandle() tokenizer = ph.datamodule.tokenizer # type: ignore[attr-defined] # protocol provides datamodule if not isinstance(raw_sequences, list): raw_sequences = [raw_sequences] if rendered_sequences is None: rendered_sequences = raw_sequences elif not isinstance(rendered_sequences, list): rendered_sequences = [rendered_sequences] if len(raw_sequences) != len(rendered_sequences): raise ValueError("raw_sequences and rendered_sequences must have the same length") debug_rows: List[Dict[str, Any]] = [] for raw_prompt, rendered_prompt in zip(raw_sequences, rendered_sequences): encoded = tokenizer(rendered_prompt, return_tensors="pt", add_special_tokens=add_special_tokens) input_ids = encoded["input_ids"][0].tolist() attention_mask = encoded.get("attention_mask") debug_rows.append( { "raw_prompt": raw_prompt, "rendered_prompt": rendered_prompt, "input_ids": input_ids, "tokens": tokenizer.convert_ids_to_tokens(input_ids), "attention_mask": attention_mask[0].tolist() if attention_mask is not None else None, "add_special_tokens": add_special_tokens, } ) return debug_rows
def _debug_generate( self, inputs: List | torch.Tensor, gen_kwargs_override: Dict | None = None, gen_config_override: Dict | None = None, gen_output_attr: str | None = None, ) -> Any: """_summary_ Args: inputs (_type_): _description_ gen_kwargs_override (Dict | None, optional): _description_. Defaults to None. gen_output_attr (str | None, optional): Specific attribute on the model output to use for decoding (e.g., `sequences`). If None, DebugGeneration.DEFAULT_OUTPUT_ATTRS are used for validation. If a raw `torch.Tensor` is returned by the model generate (e.g., HookedTransformer), the DebugGeneration extension will normalize the result into a `transformers.utils.ModelOutput` instance exposing `sequences` when appropriate (see `docs/generation_precedence.md`). Usage: ```python self.debug_lm.debug_generate_batch(['my sequence potentially with chat specific tags', 'another sequence']) # to narrow the problem space, using serial inference (non-batch mode) for a list of strings can be useful self.debug_lm.debug_generate_serial(['my sequence potentially with chat specific tags', 'another sequence']) # to override the defaults (both questions and current generation config with a different `max_new_tokens`) self.debug_lm.debug_generate_batch(self.debug_lm.chat_debug_sequences([ 'What is the color of a cloudless sky?', 'How many days are in a year?']), gen_config_override={"max_new_tokens": 25}) """ ph = self._check_phandle() # note we're not using a context manager here, keeping our new override for subsequent debugging convenience gen_kwargs = ph.it_cfg.generative_step_cfg.lm_generation_cfg.generate_kwargs if gen_kwargs_override: gen_kwargs.update(gen_kwargs_override) if gen_config_override and getattr(ph.model, "generation_config", None): # type: ignore[attr-defined] # protocol provides model for k, v in gen_config_override.items(): setattr(ph.model.generation_config, k, v) # type: ignore[attr-defined] # protocol provides model outputs = ph.it_generate(inputs, **gen_kwargs) return self._normalize_output_to_model_output(outputs, gen_output_attr) def _normalize_output_to_model_output(self, outputs: Any, gen_output_attr: str | None = None) -> Any: """Normalize outputs to a Hugging Face ModelOutput if required. - If the model returns a plain torch.Tensor (e.g. legacy HookedTransformer generate), and either gen_output_attr is unset/None or explicitly requests one of DebugGeneration.DEFAULT_OUTPUT_ATTRS, we wrap the tensor in a `ModelOutput` exposing `.sequences` attributes. - Otherwise, return the outputs unchanged. """ # If it contains any of the DEFAULT_OUTPUT_ATTRS or the requested gen_output_attr, # normalize into a ModelOutput dataclass if it's not already one. # If a requested attribute is present, or any of the standard HF attributes # are present on the output (or as dict keys), convert to a HF ModelOutput # wrapper unless it's already a ModelOutput. This is a straightforward # normalization rule to support HF-like outputs without forcing # normalization for raw tensors. requested_attrs = set(self.DEFAULT_OUTPUT_ATTRS) if gen_output_attr is not None: requested_attrs.add(gen_output_attr) has_any_attr = False if isinstance(outputs, dict): has_any_attr = any(attr in outputs for attr in requested_attrs) else: has_any_attr = any(hasattr(outputs, attr) for attr in requested_attrs) if has_any_attr: if isinstance(outputs, ModelOutput): return outputs mo_kwargs = {} if isinstance(outputs, dict): for attr in self.DEFAULT_OUTPUT_ATTRS: if attr in outputs: mo_kwargs[attr] = outputs[attr] else: for attr in self.DEFAULT_OUTPUT_ATTRS: if hasattr(outputs, attr): mo_kwargs[attr] = getattr(outputs, attr) return ModelOutput(mo_kwargs) # If it's a plain tensor (legacy HookedTransformer), return as-is if isinstance(outputs, torch.Tensor): return outputs return outputs def perplexity_on_sample( self, corpus: Dataset | Dict | None = None, stride: int | None = None, limit_chars: int | None = None, ) -> torch.Tensor: ph = self._check_phandle() if not corpus: # Fully-qualified `namespace/name`: datasets 5.x builds an `hf://datasets/<repo_id>@<rev>` # URI and huggingface_hub 1.x rejects bare legacy names ("Repository id must be # 'namespace/name'"). `wikitext` canonically resolves to `Salesforce/wikitext`. corpus_default = load_dataset("Salesforce/wikitext", "wikitext-2-raw-v1", split="test") assert isinstance(corpus_default, Dataset) corpus = corpus_default corpus_raw = "\n\n".join(corpus["text"]) corpus_max_idx = limit_chars or len(corpus_raw) encoded_corpus = ph.datamodule.tokenizer(corpus_raw[:corpus_max_idx], return_tensors="pt") # type: ignore[attr-defined] # protocol provides datamodule encoded_corpus = sanitize_input_name(self.model_input_names, encoded_corpus) perplexity_kwargs = {"stride": stride} if stride else {} return self.naive_perplexity(encoded_corpus, **perplexity_kwargs) def top1_token_accuracy_on_sample(self, sample: str) -> tuple[float, list[str]]: ph = self._check_phandle() sample_input_ids = ph.datamodule.tokenizer.encode(sample) # type: ignore[attr-defined] # protocol provides datamodule sample_input_ids = torch.tensor(sample_input_ids).to(ph.device) # type: ignore[attr-defined] # protocol provides device sample_input_ids = sample_input_ids.unsqueeze(0) with torch.no_grad(): output = ph.model(sample_input_ids) # type: ignore[attr-defined] # protocol provides model # Handle both raw tensor logits (TransformerLens) and HF-style outputs (NNsight) logits = output.logits if hasattr(output, "logits") else output prediction = logits.argmax(dim=-1).squeeze()[:-1] true_tokens = sample_input_ids.squeeze()[1:] num_correct = (prediction == true_tokens).sum() # NOTE: transformers v5 batch_decode expects 2D input (batch of sequences); # reshape 1D tensor of correct token IDs to 2D with each token as a separate sequence # using batch_decode for efficiency instead of decode in a loop correct_token_ids = prediction[prediction == true_tokens].unsqueeze(-1) correct_tokens = ph.datamodule.tokenizer.batch_decode(correct_token_ids) # type: ignore[attr-defined] # protocol provides datamodule return num_correct / len(true_tokens), correct_tokens def naive_perplexity(self, encoded_corpus, stride: int = 512) -> torch.Tensor: ph = self._check_phandle() max_length = ph.datamodule.tokenizer.model_max_length # type: ignore[attr-defined] # protocol provides datamodule corpus_ids = getattr(encoded_corpus, self.model_input_names[0]) seq_len = corpus_ids.size(1) stride = min(stride, seq_len) nlls = [] prev_end_loc = 0 loss_fn = CrossEntropyLoss() for begin_loc in range(0, seq_len, stride): end_loc = min(begin_loc + max_length, seq_len) trg_len = end_loc - prev_end_loc # may be different from stride on last loop inputs = corpus_ids[:, begin_loc:end_loc].to(ph.device) # type: ignore[attr-defined] # protocol provides device target_ids = inputs.clone() target_ids[:, :-trg_len] = -100 with torch.inference_mode(): output = ph.model.forward(inputs) # type: ignore[attr-defined] # protocol provides model # Handle both raw tensor logits (TransformerLens) and HF-style outputs (NNsight) output_logits = output.logits if hasattr(output, "logits") else output shift_logits = output_logits[..., :-1, :].contiguous() shift_target_ids = target_ids[..., 1:].contiguous() preds = shift_logits.view(-1, shift_logits.size(-1)) labels = shift_target_ids.view(-1) neg_log_likelihood = loss_fn(preds, labels) nlls.append(neg_log_likelihood) prev_end_loc = end_loc if end_loc == seq_len: break ppl = torch.exp(torch.stack(nlls).mean()) return ppl def sanitize_gen_output( self, outputs: Any, gen_output_attr: str | None = None, decode_cfg_override: Dict | None = None ) -> tuple[Any, Dict]: decode_target = self.sanitize_model_output(outputs, gen_output_attr) decode_kwargs = deepcopy(DEFAULT_DECODE_KWARGS) if decode_cfg_override: decode_kwargs.update(decode_cfg_override) return decode_target, decode_kwargs def sanitize_model_output(self, output: Any, gen_output_attr: str | None = None) -> Any: # TODO: revisit this logic after getting TL generate PR ready # For simplification, sanitization returns the requested attribute if # specified. Otherwise, return the output directly (e.g. raw tensor or # ModelOutput) without attempting further coercion. This keeps the # behavior explicit and reduces implicit conversions in test expectations. if gen_output_attr: if isinstance(output, dict): return output[gen_output_attr] if isinstance(output, torch.Tensor): # For raw tensors returned by legacy TL, if the requested attr # is one of the known DEFAULT_OUTPUT_ATTRS, we allow returning # the raw tensor for downstream decoding. Otherwise, raising # an AttributeError is appropriate. if gen_output_attr in set(self.DEFAULT_OUTPUT_ATTRS): return output raise AttributeError(f"Output tensor has no attribute {gen_output_attr}") return getattr(output, gen_output_attr) return output @property def model_input_names(self) -> list[str]: ph = self._check_phandle() return ph.datamodule.tokenizer.model_input_names # type: ignore[attr-defined] # protocol provides datamodule def debug_generate_batch( self, sequences: List, gen_output_attr: str | None = None, gen_config_override: Dict | None = None, gen_kwargs_override: Dict | None = None, decode_cfg_override: Dict | None = None, ) -> tuple[List, List]: ph = self._check_phandle() test_input_ids = ph.datamodule.tokenizer(sequences) # type: ignore[attr-defined] # protocol provides datamodule test_input_ids = sanitize_input_name(self.model_input_names, test_input_ids) test_input_ids = ph.datamodule.data_collator(test_input_ids) # type: ignore[attr-defined] # protocol provides datamodule test_input_ids = test_input_ids.to(ph.device) # type: ignore[attr-defined] # protocol provides device outputs = self._debug_generate( inputs=test_input_ids[self.model_input_names[0]], gen_config_override=gen_config_override, gen_kwargs_override=gen_kwargs_override, gen_output_attr=gen_output_attr, ) decode_target, decode_kwargs = self.sanitize_gen_output(outputs, gen_output_attr, decode_cfg_override) # If the sanitized output is a ModelOutput or dict, extract the `sequences` field # which is what the tokenizer expects for decoding. if isinstance(decode_target, ModelOutput): decode_target = decode_target.sequences # type: ignore[attr-defined] # sequences is present in GenerateDecoderOnlyOutput elif isinstance(decode_target, dict) and "sequences" in decode_target: decode_target = decode_target["sequences"] # Now decode_target should be a tensor or list-like of token ids that can be decoded. answers = ph.datamodule.tokenizer.batch_decode(decode_target, **decode_kwargs) # type: ignore[attr-defined] # protocol provides datamodule return answers, outputs def debug_generate_serial( self, sequences: List, gen_output_attr: str | None = None, gen_config_override: Dict | None = None, gen_kwargs_override: Dict | None = None, decode_cfg_override: Dict | None = None, ) -> tuple[List, List]: ph = self._check_phandle() answers = [] full_outputs = [] for seq in sequences: test_input_ids = ph.datamodule.tokenizer.encode(seq) # type: ignore[attr-defined] # protocol provides datamodule test_input_ids = torch.tensor(test_input_ids).to(ph.device) # type: ignore[attr-defined] # protocol provides device test_input_ids = test_input_ids.unsqueeze(0) output = self._debug_generate( inputs=test_input_ids, gen_config_override=gen_config_override, gen_kwargs_override=gen_kwargs_override, gen_output_attr=gen_output_attr, ) decode_target, decode_kwargs = self.sanitize_gen_output(output, gen_output_attr, decode_cfg_override) # If we received a ModelOutput/dict, get the sequences tensor for decoding if isinstance(decode_target, ModelOutput): decode_target = decode_target.sequences # type: ignore[attr-defined] # sequences is present in GenerateDecoderOnlyOutput elif isinstance(decode_target, dict) and "sequences" in decode_target: decode_target = decode_target["sequences"] sequences = decode_target.unbind() # type: ignore[union-attr] # decode_target is tensor at this point decode_kwargs = deepcopy(DEFAULT_DECODE_KWARGS) if decode_cfg_override: decode_kwargs.update(decode_cfg_override) for seq in sequences: # in case num_return_sequences > 1 answers.append(ph.datamodule.tokenizer.decode(seq, **decode_kwargs)) # type: ignore[attr-defined] # protocol provides datamodule full_outputs.append(output) return answers, full_outputs