Skip to content

FEAT analytics: analyze attack success rate with generic dimensions#1362

Open
romanlutz wants to merge 14 commits intoAzure:mainfrom
romanlutz:romanlutz/analytics_asr_by_generic_dimensions
Open

FEAT analytics: analyze attack success rate with generic dimensions#1362
romanlutz wants to merge 14 commits intoAzure:mainfrom
romanlutz:romanlutz/analytics_asr_by_generic_dimensions

Conversation

@romanlutz
Copy link
Contributor

Description

Generalizing analyze_results to work with attack type, converter type, labels, or any dimension we can define from attack results.

Tests and Documentation

unit tests and new notebook


# Resolve deprecated aliases and validate dimension names
resolved_group_by: list[Union[str, tuple[str, ...]]] = []
for spec in group_by:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we check whether there are repeats in group_by? Fringe case but counts could be off in that specific case.

"## Composite Dimensions\n",
"\n",
"Use a tuple of dimension names to create a cross-product grouping. For example,\n",
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"crescendo\")`."
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"crescendo\")`."
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"CrescendoAttack\")`."

extractors.update(custom_dimensions)

# Resolve group_by — default to every registered dimension independently
if group_by is None:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should we warn if group_by = [] but custom_dimensions is non-empty?


def _compute_stats(successes: int, failures: int, undetermined: int) -> AttackStats:
@dataclass
class AnalysisResult:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe not this PR, but should we have a way to pretty print the result?

else:
# Composite: cross-product of all sub-dimension values
sub_values = [extractors[name](attack) for name in spec]
for combo in product(*sub_values):
Copy link
Contributor

@jsong468 jsong468 Feb 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be going down a rabbithole but I'm imagining that if there is an attack (attack_type_x) with multiple converters (let's say c1 and c2), then this cross product will include (c1, attack_type_x) and (c2, attack_type_y) (and also get more complicated if we have, say, custom dimensions that are one to many). I wonder if that's a bit misleading to a user because it wasn't really c1 attack_type_x that led to a specific result, it was the combination of both converters with attack_type_x. Maybe combinations of converters or other dimensions used in one attack should be treated as one entity?

@romanlutz romanlutz force-pushed the romanlutz/analytics_asr_by_generic_dimensions branch from 285149a to 866599e Compare March 2, 2026 22:04
Copilot AI review requested due to automatic review settings March 2, 2026 22:04
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR generalizes the analyze_results function in pyrit/analytics to support flexible, dimension-based grouping of attack results. Previously the function only returned overall stats and a breakdown by attack identifier. The new design supports arbitrary dimensions (built-in or custom), composite cross-product groupings, and a structured AnalysisResult return type. A DeprecationWarning bridges the old attack_identifier group key to the new attack_type canonical name.

Changes:

  • Refactored analyze_results in pyrit/analytics/result_analysis.py to support pluggable dimension extractors, composite groupings, custom dimensions, and a new AnalysisResult dataclass return type.
  • Expanded unit tests in tests/unit/analytics/test_result_analysis.py with grouped test classes covering validation, per-dimension, composite, custom, and deprecated alias scenarios.
  • Added a new documentation notebook (doc/code/analytics/1_result_analysis.py + .ipynb) showing usage of all new features, and registered it in doc/_toc.yml.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
pyrit/analytics/result_analysis.py Core refactor: new AnalysisResult, DimensionExtractor, built-in extractors, dimension alias support, and generalized analyze_results function
pyrit/analytics/__init__.py Exports new public types AnalysisResult and DimensionExtractor
tests/unit/analytics/test_result_analysis.py Comprehensive test expansion with organized test classes and new scenarios
doc/code/analytics/1_result_analysis.py New JupyText source notebook — contains critical bugs (non-existent import, wrong constructor arguments, raw dict as attack_identifier)
doc/code/analytics/1_result_analysis.ipynb Rendered notebook — mirrors the same bugs from the .py source
doc/_toc.yml Registers the new analytics notebook in the documentation table of contents

Comment on lines +56 to +66
"# Realistic attack_identifier dicts mirror Strategy.get_identifier() output\n",
"crescendo_id = {\n",
" \"__type__\": \"CrescendoAttack\",\n",
" \"__module__\": \"pyrit.executor.attack.multi_turn.crescendo\",\n",
" \"id\": \"a1b2c3d4-0001-4000-8000-000000000001\",\n",
"}\n",
"red_team_id = {\n",
" \"__type__\": \"RedTeamingAttack\",\n",
" \"__module__\": \"pyrit.executor.attack.multi_turn.red_teaming\",\n",
" \"id\": \"a1b2c3d4-0002-4000-8000-000000000002\",\n",
"}\n",
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebook constructs AttackResult objects using raw dicts for attack_identifier (e.g., crescendo_id = {"__type__": "CrescendoAttack", ...}). AttackResult is a @dataclass with no __post_init__ that would coerce a dict to ComponentIdentifier. When analyze_results processes these results, _extract_attack_type calls result.attack_identifier.class_name, which will raise an AttributeError since a dict has no .class_name attribute. The notebook should use ComponentIdentifier objects (or ComponentIdentifier.from_dict()) instead of raw dicts for attack_identifier.

Copilot uses AI. Check for mistakes.
Comment on lines +30 to +41
from pyrit.identifiers import ConverterIdentifier
from pyrit.models import AttackOutcome, AttackResult, MessagePiece


def make_converter(name: str) -> ConverterIdentifier:
return ConverterIdentifier(
class_name=name,
class_module="pyrit.prompt_converter",
class_description=f"{name} converter",
identifier_type="instance",
supported_input_types=("text",),
supported_output_types=("text",),
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebook and .py documentation file import ConverterIdentifier from pyrit.identifiers, but this class does not exist in the codebase. Only ComponentIdentifier is defined and exported from pyrit.identifiers. Additionally, the make_converter function passes fields that ComponentIdentifier does not support (class_description, identifier_type, supported_input_types, supported_output_types). This will cause an ImportError at runtime when the notebook is executed. The import and function body should use ComponentIdentifier(class_name=name, class_module="pyrit.prompt_converter") instead.

Suggested change
from pyrit.identifiers import ConverterIdentifier
from pyrit.models import AttackOutcome, AttackResult, MessagePiece
def make_converter(name: str) -> ConverterIdentifier:
return ConverterIdentifier(
class_name=name,
class_module="pyrit.prompt_converter",
class_description=f"{name} converter",
identifier_type="instance",
supported_input_types=("text",),
supported_output_types=("text",),
from pyrit.identifiers import ComponentIdentifier
from pyrit.models import AttackOutcome, AttackResult, MessagePiece
def make_converter(name: str) -> ComponentIdentifier:
return ComponentIdentifier(
class_name=name,
class_module="pyrit.prompt_converter",

Copilot uses AI. Check for mistakes.
Comment on lines +41 to +52
"from pyrit.identifiers import ConverterIdentifier\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> ConverterIdentifier:\n",
" return ConverterIdentifier(\n",
" class_name=name,\n",
" class_module=\"pyrit.prompt_converter\",\n",
" class_description=f\"{name} converter\",\n",
" identifier_type=\"instance\",\n",
" supported_input_types=(\"text\",),\n",
" supported_output_types=(\"text\",),\n",
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebook imports ConverterIdentifier from pyrit.identifiers and constructs it with parameters (class_description, identifier_type, supported_input_types, supported_output_types) that don't exist in ComponentIdentifier. ConverterIdentifier is not exported from pyrit.identifiers — only ComponentIdentifier is. This will cause an ImportError when the notebook cell is executed. The import and make_converter function body should use ComponentIdentifier(class_name=name, class_module="pyrit.prompt_converter") instead.

Suggested change
"from pyrit.identifiers import ConverterIdentifier\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> ConverterIdentifier:\n",
" return ConverterIdentifier(\n",
" class_name=name,\n",
" class_module=\"pyrit.prompt_converter\",\n",
" class_description=f\"{name} converter\",\n",
" identifier_type=\"instance\",\n",
" supported_input_types=(\"text\",),\n",
" supported_output_types=(\"text\",),\n",
"from pyrit.identifiers import ComponentIdentifier\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> ComponentIdentifier:\n",
" return ComponentIdentifier(\n",
" class_name=name,\n",
" class_module=\"pyrit.prompt_converter\",\n",

Copilot uses AI. Check for mistakes.
# ## Composite Dimensions
#
# Use a tuple of dimension names to create a cross-product grouping. For example,
# `("converter_type", "attack_type")` produces keys like `("Base64Converter", "crescendo")`.
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The markdown comment says ("converter_type", "attack_type") produces keys like ("Base64Converter", "crescendo"), but the actual output in the notebook shows ("Base64Converter", "CrescendoAttack"). The example key in the comment is incorrect and should be ("Base64Converter", "CrescendoAttack").

Suggested change
# `("converter_type", "attack_type")` produces keys like `("Base64Converter", "crescendo")`.
# `("converter_type", "attack_type")` produces keys like `("Base64Converter", "CrescendoAttack")`.

Copilot uses AI. Check for mistakes.
Comment on lines +46 to +55
crescendo_id = {
"__type__": "CrescendoAttack",
"__module__": "pyrit.executor.attack.multi_turn.crescendo",
"id": "a1b2c3d4-0001-4000-8000-000000000001",
}
red_team_id = {
"__type__": "RedTeamingAttack",
"__module__": "pyrit.executor.attack.multi_turn.red_teaming",
"id": "a1b2c3d4-0002-4000-8000-000000000002",
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebook constructs AttackResult objects using raw dicts for attack_identifier (e.g., crescendo_id = {"__type__": "CrescendoAttack", ...}). However, AttackResult is a plain @dataclass with no __post_init__ that would coerce a dict to a ComponentIdentifier. When analyze_results processes these results, _extract_attack_type calls result.attack_identifier.class_name, which will raise an AttributeError because a dict has no .class_name attribute. The notebook should use ComponentIdentifier objects (or the ComponentIdentifier.from_dict() factory) instead of raw dicts for attack_identifier.

Copilot uses AI. Check for mistakes.
Copilot AI review requested due to automatic review settings March 3, 2026 04:50
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.

"## Composite Dimensions\n",
"\n",
"Use a tuple of dimension names to create a cross-product grouping. For example,\n",
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"crescendo\")`."
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same documentation inaccuracy as in the .py file: the markdown comment claims ("converter_type", "attack_type") produces keys like ("Base64Converter", "crescendo"), but the actual output in the same cell (line 340) shows ('Base64Converter', 'CrescendoAttack'). The example should say ("Base64Converter", "CrescendoAttack"), not ("Base64Converter", "crescendo").

Suggested change
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"crescendo\")`."
"`(\"converter_type\", \"attack_type\")` produces keys like `(\"Base64Converter\", \"CrescendoAttack\")`."

Copilot uses AI. Check for mistakes.
canonical = _DEPRECATED_DIMENSION_ALIASES.get(name)
if canonical and canonical in extractors:
warnings.warn(
f"Dimension '{name}' is deprecated and will be removed in v0.13.0. Use '{canonical}' instead.",
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The deprecation warning message uses the format "will be removed in v0.13.0" (with a v prefix), but other deprecation messages throughout the codebase consistently use "will be removed in 0.13.0" (without the v prefix). For example, see pyrit/models/message.py:190 and pyrit/models/scenario_result.py:71. This should be updated to match the established convention.

Suggested change
f"Dimension '{name}' is deprecated and will be removed in v0.13.0. Use '{canonical}' instead.",
f"Dimension '{name}' is deprecated and will be removed in 0.13.0. Use '{canonical}' instead.",

Copilot uses AI. Check for mistakes.
Comment on lines +40 to +53
"from pyrit.analytics import analyze_results\n",
"from pyrit.identifiers import ConverterIdentifier\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> ConverterIdentifier:\n",
" return ConverterIdentifier(\n",
" class_name=name,\n",
" class_module=\"pyrit.prompt_converter\",\n",
" class_description=f\"{name} converter\",\n",
" identifier_type=\"instance\",\n",
" supported_input_types=(\"text\",),\n",
" supported_output_types=(\"text\",),\n",
" )\n",
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same ConverterIdentifier import and usage issue exists in the notebook's .ipynb form. The import on line 41 and the make_converter function using ConverterIdentifier (lines 45-53) will raise ImportError: cannot import name 'ConverterIdentifier' from 'pyrit.identifiers' when the notebook is executed.

Suggested change
"from pyrit.analytics import analyze_results\n",
"from pyrit.identifiers import ConverterIdentifier\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> ConverterIdentifier:\n",
" return ConverterIdentifier(\n",
" class_name=name,\n",
" class_module=\"pyrit.prompt_converter\",\n",
" class_description=f\"{name} converter\",\n",
" identifier_type=\"instance\",\n",
" supported_input_types=(\"text\",),\n",
" supported_output_types=(\"text\",),\n",
" )\n",
"from typing import Dict\n",
"from pyrit.analytics import analyze_results\n",
"from pyrit.models import AttackOutcome, AttackResult, MessagePiece\n",
"\n",
"\n",
"def make_converter(name: str) -> Dict[str, str]:\n",
" return {\n",
" \"__type__\": name,\n",
" \"__module__\": \"pyrit.prompt_converter\",\n",
" }\n",

Copilot uses AI. Check for mistakes.
Comment on lines +44 to +116

# Realistic attack_identifier dicts mirror Strategy.get_identifier() output
crescendo_id = {
"__type__": "CrescendoAttack",
"__module__": "pyrit.executor.attack.multi_turn.crescendo",
"id": "a1b2c3d4-0001-4000-8000-000000000001",
}
red_team_id = {
"__type__": "RedTeamingAttack",
"__module__": "pyrit.executor.attack.multi_turn.red_teaming",
"id": "a1b2c3d4-0002-4000-8000-000000000002",
}

# Build a small set of representative attack results
results = [
# Crescendo attacks with Base64Converter
AttackResult(
conversation_id="c1",
objective="bypass safety filter",
attack_identifier=crescendo_id,
outcome=AttackOutcome.SUCCESS,
last_response=MessagePiece(
role="user",
original_value="response 1",
converter_identifiers=[make_converter("Base64Converter")],
labels={"operation_name": "op_safety_bypass", "operator": "alice"},
),
),
AttackResult(
conversation_id="c2",
objective="bypass safety filter",
attack_identifier=crescendo_id,
outcome=AttackOutcome.FAILURE,
last_response=MessagePiece(
role="user",
original_value="response 2",
converter_identifiers=[make_converter("Base64Converter")],
labels={"operation_name": "op_safety_bypass", "operator": "alice"},
),
),
# Red teaming attacks with ROT13Converter
AttackResult(
conversation_id="c3",
objective="extract secrets",
attack_identifier=red_team_id,
outcome=AttackOutcome.SUCCESS,
last_response=MessagePiece(
role="user",
original_value="response 3",
converter_identifiers=[make_converter("ROT13Converter")],
labels={"operation_name": "op_secret_extract", "operator": "bob"},
),
),
AttackResult(
conversation_id="c4",
objective="extract secrets",
attack_identifier=red_team_id,
outcome=AttackOutcome.SUCCESS,
last_response=MessagePiece(
role="user",
original_value="response 4",
converter_identifiers=[make_converter("ROT13Converter")],
labels={"operation_name": "op_secret_extract", "operator": "bob"},
),
),
# An undetermined result (no converter, no labels)
AttackResult(
conversation_id="c5",
objective="test prompt",
attack_identifier=crescendo_id,
outcome=AttackOutcome.UNDETERMINED,
),
]
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The notebook passes plain Python dicts (e.g., crescendo_id, red_team_id) as the attack_identifier argument when constructing AttackResult objects. Unlike MessagePiece, the AttackResult dataclass has no __post_init__ and performs no normalization, so the dict is stored as-is on attack.attack_identifier. When _extract_attack_type accesses result.attack_identifier.class_name, it tries to access .class_name on a plain dict, which raises AttributeError: 'dict' object has no attribute 'class_name'. The notebook should pass a ComponentIdentifier instance directly instead of a raw dict.

Copilot uses AI. Check for mistakes.
romanlutz and others added 2 commits March 2, 2026 21:01
…icts

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 3, 2026 05:22
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 4 comments.



# ---------------------------------------------------------------------------
# Single dimension: attack_identifier
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The section comment header says "Single dimension: attack_identifier" but the class below it is TestGroupByAttackType, which groups by attack_type. The dimension was renamed to attack_type, so the comment should be updated to "Single dimension: attack_type" to stay consistent with the rest of the file and reflect the current dimension name.

Suggested change
# Single dimension: attack_identifier
# Single dimension: attack_type

Copilot uses AI. Check for mistakes.
Comment on lines +170 to +173
warnings.warn(
f"Dimension '{name}' is deprecated and will be removed in v0.13.0. Use '{canonical}' instead.",
DeprecationWarning,
stacklevel=4,
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The stacklevel=4 in _resolve_dimension_name is correct when the caller path is analyze_results_resolve_dimension_spec (simple string, line 189) → _resolve_dimension_name (4 frames). However, when a composite tuple triggers this path via the generator expression tuple(_resolve_dimension_name(...) for n in spec) at line 190, an extra stack frame is introduced by the generator, making the effective call stack 5 frames deep. As a result, the deprecation warning's source location will incorrectly point to _resolve_dimension_spec rather than the user's analyze_results call site when the deprecated alias is used inside a composite tuple. Consider passing stacklevel as a parameter to _resolve_dimension_name, or passing a flag to use a different value when invoked from a generator context.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +96
DEFAULT_DIMENSIONS: dict[str, DimensionExtractor] = {
"attack_type": _extract_attack_type,
"converter_type": _extract_converter_types,
"label": _extract_labels,
}
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The DEFAULT_DIMENSIONS constant uses a public naming convention (uppercase, no underscore) suggesting it is part of the public API. However, it is not exported in pyrit/analytics/__init__.py's __all__ list, while DimensionExtractor (needed together with DEFAULT_DIMENSIONS for extensions) is exported. Users who want to extend or inspect the built-in dimension registry (a reasonable use case given the custom dimensions feature) would need to import directly from pyrit.analytics.result_analysis, which is not documented. Either export DEFAULT_DIMENSIONS in __all__, or prefix it with an underscore to signal it is internal.

Copilot uses AI. Check for mistakes.
- file: code/scoring/prompt_shield_scorer
- file: code/scoring/generic_scorers
- file: code/scoring/8_scorer_metrics
- file: code/analytics/1_result_analysis
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The 1_result_analysis notebook is added to _toc.yml at the top level with no parent page or section, positioned between the scoring section and the memory section. By contrast, other notebooks like scoring (code/scoring/0_scoring) have a dedicated parent section entry. The analytics notebook should either have a parent analytics section page (e.g., code/analytics/0_analytics) or be nested under an existing relevant section, rather than appearing as a standalone top-level entry without a parent section.

Suggested change
- file: code/analytics/1_result_analysis
- file: code/analytics/0_analytics
sections:
- file: code/analytics/1_result_analysis

Copilot uses AI. Check for mistakes.
romanlutz and others added 2 commits March 2, 2026 21:30
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants