feat: add response length scorer (#562)

This commit is contained in:
Philipp Emanuel Weidmann authored and GitHub committed 2026-11-05 11:56:41 +05:30
1 parent f4cf535403
commit 21c68d0a94
7 files changed
+187 -8

No files matched your search

+22 -1
View File
@@ -163,7 +163,7 @@ column = "text"
# Name that describes what the configured keyword rate measures.
score_name = "Refusals"
# Whether to print prompt/response pairs when counting keyword matches.
# Whether to print prompt/response pairs.
print_responses = false
# Strings whose presence in a response (case insensitive) identifies the response as a keyword match.
@@ -293,6 +293,27 @@ task = "piqa"
metric = "acc_norm,none"
[scorer.ResponseLength]
# Whether to strip leading and trailing whitespace from responses before measuring their length.
strip_responses = true
# Length in characters below which a response is classified as a 'short response'.
short_response_threshold = 2 # Chosen because "No" is the shortest common valid response.
# Length value artificially assigned to 'short responses' for the purpose of scoring.
# This can be used to penalize very short (and thus likely invalid) responses.
short_response_score_length = 500
# Whether to print prompt/response pairs.
print_responses = false
# Dataset of prompts to evaluate the response length on.
[scorer.ResponseLength.prompts]
dataset = "mlabonne/harmless_alpaca"
split = "test[:100]"
column = "text"
[modifier.ARA]
# Whether to renormalize the rows of the modified matrices to preserve
# the original matrices' row magnitudes. This is believed to improve
+51
View File
@@ -0,0 +1,51 @@
# Rename this file to config.toml, place it in the working directory
# that you run Heretic from, and edit the configuration to your liking.
scorers = [
{ plugin = "heretic.scorers.response_length.ResponseLength", optimization = "minimize" },
# We cannot use KL divergence because there is no set of prompts for which
# we expect the modified model to retain its behavior.
{ plugin = "heretic.scorers.benchmark_score.BenchmarkScore", optimization = "maximize" },
]
modified_model_type = "tersified"
upload_tags = [
"terse",
]
[modifier.ARA.good_prompts]
dataset = "mlabonne/harmless_alpaca"
split = "train[:400]"
column = "text"
system_prompt = """You are a hyper-terse assistant. You respond with the minimum amount of text that satisfies the prompt.
You don't use introductions, you don't repeat or rephrase the task, you don't provide additional information,
and you don't use full sentences when a single word or item suffices.
Here are some examples showing how you should, and shouldn't, respond:
User: What is 1+1?
Good response: 2
Bad response: 1 + 1 = 2! This is a simple addition operation.
User: What is the largest land animal?
Good response: African elephant
Bad response: The largest land animal is the African bush elephant (*Loxodonta africana*),
with fully grown bulls reaching an average shoulder height of 3.3 meters, and a body mass of 6 tons.
User: Describe a grocery shopping trip.
Good response: Go to store, buy groceries, return
Bad response: First, make sure you have a shopping list. Then pack your shopping bags
into your car and drive to the grocery store. Make sure you park as closely as possible
to the entrance of the store. Once inside, pick up a cart and check the aisles for the items you need.
Collect all items in your cart and proceed to the register. Pay for the items and take the cart
to your car. Move the items from the cart into the trunk of your car. Then return the cart to the store,
return to your car, and drive home."""
[modifier.ARA.bad_prompts]
dataset = "mlabonne/harmless_alpaca"
split = "train[:400]"
column = "text"
system_prompt = """You are a loquacious personal assistant. You give elaborate, highly detailed responses to every prompt.
Your responses contain proper introductions and start with a summary of the task or question.
You supply as much additional information as possible and write everything out in full detail."""
+3 -1
View File
@@ -174,7 +174,9 @@ class Evaluator:
"""
records: list[dict[str, Any]] = []
for (name, score), (baseline_name, baseline) in zip(
scores, self.baseline_scores
scores,
self.baseline_scores,
strict=True,
):
assert name == baseline_name, (
f"Score/baseline order mismatch: {name!r} != {baseline_name!r}"
-2
View File
@@ -53,8 +53,6 @@ class Scorer(Plugin, ABC):
def get_score(self, ctx: Context) -> Score:
"""
Return a `Score` given the evaluation context.
The `value` of the `Score` must be of the order of magnitude 1
to ensure that all scores are comparable during co-optimization.
"""
def get_baseline_score(self, ctx: Context) -> Score:
+7 -2
View File
@@ -215,7 +215,12 @@ class BERTClassifier(Scorer):
predicted_labels = self._classify(responses)
match_count = 0
for prompt, response, label in zip(self.prompts, responses, predicted_labels):
for prompt, response, label in zip(
self.prompts,
responses,
predicted_labels,
strict=True,
):
# Evaluate matches based on the classifier's predicted label only.
is_match = label in self.settings.match_labels
match_count += int(is_match)
@@ -253,7 +258,7 @@ class BERTClassifier(Scorer):
"text": prompt.user,
"text_pair": response,
}
for prompt, response in zip(self.prompts, responses)
for prompt, response in zip(self.prompts, responses, strict=True)
]
else:
# Fallback for single-sequence text-classification models
+3 -2
View File
@@ -78,7 +78,7 @@ class Settings(BaseModel):
print_responses: bool = Field(
default=False,
description="Whether to print prompt/response pairs when counting keyword matches.",
description="Whether to print prompt/response pairs.",
)
@@ -110,7 +110,8 @@ class KeywordRate(Scorer):
def get_score(self, ctx: Context) -> Score:
match_count = 0
responses = ctx.get_responses(self.prompts)
for prompt, response in zip(self.prompts, responses):
for prompt, response in zip(self.prompts, responses, strict=True):
is_match = self._is_match(response)
if is_match:
match_count += 1
+101
View File
@@ -0,0 +1,101 @@
# SPDX-License-Identifier: AGPL-3.0-or-later
# Copyright (C) 2025-2026 Philipp Emanuel Weidmann <pew@worldwidemann.com> + contributors
from pydantic import BaseModel, Field, NonNegativeInt
from rich.markup import escape
from heretic.config import DatasetSpecification, SingleDatasetSpecification
from heretic.scorer import Context, Score, Scorer
from heretic.utils import format_dataset_specification, print
class Settings(BaseModel):
prompts: DatasetSpecification = Field(
default=SingleDatasetSpecification(
dataset="mlabonne/harmless_alpaca",
split="test[:100]",
column="text",
),
description="Dataset of prompts to evaluate the response length on.",
)
strip_responses: bool = Field(
default=True,
description="Whether to strip leading and trailing whitespace from responses before measuring their length.",
)
short_response_threshold: NonNegativeInt = Field(
# Chosen because "No" is the shortest common valid response.
default=2,
description="Length in characters below which a response is classified as a 'short response'.",
)
short_response_score_length: float = Field(
default=500,
description=(
"Length value artificially assigned to 'short responses' for the purpose of scoring. "
"This can be used to penalize very short (and thus likely invalid) responses."
),
)
print_responses: bool = Field(
default=False,
description="Whether to print prompt/response pairs.",
)
class ResponseLength(Scorer):
"""
Measures the mean length of responses.
"""
settings: Settings
@property
def reproducible(self) -> bool:
return True
@property
def score_name(self) -> str:
return "Mean response length"
def init(self, ctx: Context) -> None:
print()
print(
f"Loading response length evaluation prompts from [bold]{format_dataset_specification(self.settings.prompts)}[/]..."
)
self.prompts = ctx.load_prompts(self.settings.prompts)
print(f"* [bold]{len(self.prompts)}[/] prompts loaded")
def get_score(self, ctx: Context) -> Score:
total_length = 0
responses = ctx.get_responses(self.prompts)
for prompt, response in zip(self.prompts, responses, strict=True):
response_length = len(
response.strip() if self.settings.strip_responses else response
)
if response_length < self.settings.short_response_threshold:
response_length = self.settings.short_response_score_length
total_length += response_length
if self.settings.print_responses:
print()
print(f"[bold]System prompt:[/] {escape(prompt.system)}")
print(f"[bold]Prompt:[/] {escape(prompt.user)}")
if not response.strip():
response = "[italic]\\[empty][/]"
else:
response = escape(response)
print(f"[bold]Response:[/] {response}")
if self.settings.print_responses:
print()
mean_length = float(total_length / len(self.prompts))
return Score(
value=mean_length,
rich_display=f"[bold]{mean_length:.1f}[/]",
md_display=f"{mean_length:.1f}",
)