Skip to content

Commit fecc545

Browse files
immu4989romanlutz
andauthored
FIX guard ttest_1samp against zero-variance diff in HarmScorerEvaluator (microsoft#1807)
Co-authored-by: Roman Lutz <romanlutz13@gmail.com>
1 parent af46830 commit fecc545

3 files changed

Lines changed: 42 additions & 3 deletions

File tree

pyrit/score/scorer_evaluation/scorer_evaluator.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -585,7 +585,21 @@ def _compute_metrics(
585585
diff[np.abs(diff) < 1e-10] = 0.0
586586

587587
abs_error = np.abs(diff)
588-
t_statistic, p_value = cast("tuple[float, float]", ttest_1samp(diff, 0))
588+
# ttest_1samp on a zero-variance sample returns NaN and emits scipy
589+
# divide-by-zero / catastrophic-cancellation warnings. Two degenerate cases
590+
# warrant explicit handling (np.allclose tolerates the float noise that
591+
# creeps in from `np.median(...)` differences):
592+
# - Perfect agreement (diff effectively all zeros): the null hypothesis
593+
# (mean diff = 0) is exactly satisfied, so report t=0.0, p=1.0.
594+
# - Systematic bias with no variance (constant non-zero diff): the t-test
595+
# is undefined; report NaN explicitly. MAE captures the bias magnitude.
596+
if diff.size > 0 and np.allclose(diff, diff[0]):
597+
if np.isclose(diff[0], 0.0):
598+
t_statistic, p_value = 0.0, 1.0
599+
else:
600+
t_statistic, p_value = float("nan"), float("nan")
601+
else:
602+
t_statistic, p_value = cast("tuple[float, float]", ttest_1samp(diff, 0))
589603

590604
num_responses = all_human_scores.shape[1]
591605
num_human_raters = all_human_scores.shape[0]

pyrit/score/scorer_evaluation/scorer_metrics.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,9 +94,13 @@ class HarmScorerMetrics(ScorerMetrics):
9494
a confidence interval for the mean absolute error.
9595
t_statistic (float): The t-statistic for the one-sample t-test comparing model scores to human scores with a
9696
null hypothesis that the mean difference is 0. A high positive t-statistic (along with a low p-value)
97-
indicates that the model scores are typically higher than the human scores.
97+
indicates that the model scores are typically higher than the human scores. When the model perfectly
98+
agrees with the gold labels (zero difference everywhere), this is reported as 0.0. When all differences
99+
are equal and non-zero (a systematic constant bias with no variance), the t-test is undefined and this
100+
is reported as NaN; consult `mean_absolute_error` for the bias magnitude in that case.
98101
p_value (float): The p-value for the one-sample t-test above. It represents the probability of obtaining a
99102
difference in means as extreme as the observed difference, assuming the null hypothesis is true.
103+
Reported as 1.0 on perfect agreement and NaN on the constant-non-zero-bias case (see `t_statistic`).
100104
krippendorff_alpha_combined (float): Krippendorff's alpha for the reliability data, which includes both
101105
human and model scores. This measures the agreement between all the human raters and model scoring trials
102106
and ranges between -1.0 to 1.0 where 1.0 indicates perfect agreement, 0.0 indicates no agreement, and

tests/unit/score/test_scorer_evaluator.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,20 +170,41 @@ def test_compute_harm_metrics_perfect_agreement(mock_harm_scorer):
170170
)
171171
assert metrics.mean_absolute_error == 0.0
172172
assert metrics.mae_standard_error == 0.0
173+
# Perfect agreement: diff is all zeros, t-test guarded to avoid NaN propagation.
174+
assert metrics.t_statistic == 0.0
175+
assert metrics.p_value == 1.0
173176
assert metrics.krippendorff_alpha_combined == 1.0
174177
assert metrics.krippendorff_alpha_humans == 1.0
175178
assert metrics.krippendorff_alpha_model == 1.0
176179

177180

178181
def test_compute_harm_metrics_partial_agreement(mock_harm_scorer):
179182
evaluator = HarmScorerEvaluator(scorer=mock_harm_scorer)
180-
# 2 responses, 3 human scores each, model is off by 0.1 for each
183+
# 2 responses, 3 human scores each, model is off by 0.1 for each (constant bias, zero variance)
181184
all_human_scores = np.array([[0.1, 0.2], [0.1, 0.2], [0.1, 0.2]])
182185
all_model_scores = np.array([[0.2, 0.3], [0.2, 0.3]])
183186
metrics = evaluator._compute_metrics(
184187
all_human_scores=all_human_scores, all_model_scores=all_model_scores, num_scorer_trials=2
185188
)
186189
assert np.isclose(metrics.mean_absolute_error, 0.1)
190+
# Constant non-zero diff has no within-sample variance: t-test undefined, reported as NaN.
191+
# MAE captures the bias magnitude.
192+
assert np.isnan(metrics.t_statistic)
193+
assert np.isnan(metrics.p_value)
194+
195+
196+
def test_compute_harm_metrics_partial_agreement_with_variance(mock_harm_scorer):
197+
evaluator = HarmScorerEvaluator(scorer=mock_harm_scorer)
198+
# Model scores have variance across responses so ttest_1samp is well-defined.
199+
all_human_scores = np.array([[0.1, 0.5], [0.1, 0.5], [0.1, 0.5]])
200+
all_model_scores = np.array([[0.2, 0.3], [0.2, 0.3]])
201+
metrics = evaluator._compute_metrics(
202+
all_human_scores=all_human_scores, all_model_scores=all_model_scores, num_scorer_trials=2
203+
)
204+
# diff = [0.1, -0.2]; both t_statistic and p_value should be finite floats.
205+
assert np.isfinite(metrics.t_statistic)
206+
assert np.isfinite(metrics.p_value)
207+
assert 0.0 <= metrics.p_value <= 1.0
187208

188209

189210
@patch("pyrit.score.scorer_evaluation.scorer_evaluator.find_objective_metrics_by_eval_hash")

0 commit comments

Comments
 (0)