ranklens.stats¶
ranklens.stats
¶
Statistics of comparing two runs: is the difference real or is it noise?
Which hypothesis. Everything here is about one number: the mean over queries of the per-query difference of one metric at one cutoff (for example NDCG@10), B minus A.
permutation_testtests H0: the per-query differences are symmetric around zero, that is, for every query it makes no difference which run is called A and which B. Rejecting H0 means such a mean difference is unlikely to come from chance alone.paired_bootstraptests nothing: it says how precisely that mean is measured if the queries at hand are a sample of a larger population of queries.benjamini_hochbergkeeps many comparisons honest: over 40 segments a couple of "significant" ones appear by chance, so reports show q-values.ppi_comparecompares on cheap (proxy) judgements corrected by a gold sample: a systematic error of the cheap judgements would bias the difference, and no resampling of queries could see it.minimum_detectable_effectanswers the question to ask first: is this many queries enough to see the effect we care about?- The unit of observation is the query. The inference is about the population of queries, not about documents, sessions or users.
What these do not answer. Not "do the two rankings differ": two runs can put documents
in completely different orders and still have the same NDCG — for the orders themselves
use ranklens.metrics.rbo. And not "will users notice": that is an online experiment.
MIN_QUERIES
module-attribute
¶
MIN_QUERIES = 20
Below this the interval is so wide that it says little; a warning, not an error.
MIN_GOLD
module-attribute
¶
MIN_GOLD = 30
Fewer gold queries give a correction too noisy to rely on; a warning is emitted.
paired_bootstrap
¶
paired_bootstrap(
deltas: Sequence[float] | Deltas,
*,
n_resamples: int = 10000,
alpha: float = 0.05,
seed: int = 0,
) -> BootstrapInterval
Percentile confidence interval of the mean difference.
Queries are resampled with replacement n_resamples times; the interval is the
alpha/2 and 1 - alpha/2 quantiles of the resampled means. Resampling happens
in memory-bounded chunks (see ranklens.stats.chunking).
Source code in src/ranklens/stats/bootstrap.py
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 | |
paired_deltas
¶
paired_deltas(
per_query_a: Mapping[QueryId, float],
per_query_b: Mapping[QueryId, float],
*,
min_queries: int = MIN_QUERIES,
) -> tuple[tuple[QueryId, ...], Deltas]
Queries measured in both runs, sorted, and their per-query differences b - a.
Queries present in only one of the runs are dropped: a paired test needs pairs.
With no common queries at all there is nothing to compare — that is
InsufficientSampleError. With fewer than min_queries the numbers are
computable but unreliable, so they are produced with a SmallSampleWarning.
Source code in src/ranklens/stats/bootstrap.py
27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 | |
adjust
¶
adjust(
results: Sequence[Comparison],
) -> tuple[Comparison, ...]
Same comparisons with q_value filled in; significant then uses it.
Works for ComparisonResult and PPIComparison alike: BH needs only p-values.
Source code in src/ranklens/stats/multiple.py
48 49 50 51 52 53 54 55 56 57 | |
benjamini_hochberg
¶
benjamini_hochberg(
p_values: Sequence[float] | NDArray[float64],
) -> tuple[float, ...]
q-values of the Benjamini-Hochberg procedure, in the order of the input.
For p-values sorted ascending, q(i) = min over j >= i of (m / j) * p(j), capped at 1. A comparison is a discovery at level alpha when its q-value is below alpha.
Source code in src/ranklens/stats/multiple.py
21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 | |
permutation_test
¶
permutation_test(
deltas: Sequence[float] | Deltas,
*,
n_permutations: int = 10000,
seed: int = 0,
) -> float
Two-sided p-value against H0: the per-query differences are symmetric around zero.
In other words, under H0 it makes no difference for any query which run is called A
and which B, so the sign of each difference is arbitrary. The test flips signs at
random and counts how often the mean difference comes out at least as extreme as the
observed one. The p-value is (b + 1) / (m + 1): the observed assignment belongs to
the reference set, so it is never 0 — with 10k permutations the smallest reportable
value is about 1e-4.
A small p-value says the mean difference of this metric is unlikely to be noise
of the query sample. It says nothing about how differently the two runs order
documents (see ranklens.metrics.rbo) or about the effect on users.
Source code in src/ranklens/stats/permutation.py
16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 | |
minimum_detectable_effect
¶
minimum_detectable_effect(
sd: float,
n_queries: int,
*,
alpha: float = 0.05,
power: float = 0.8,
) -> float
Smallest true difference that would be detected with probability power.
MDE = (z(1 - alpha/2) + z(power)) * sd / sqrt(n), where sd is the standard
deviation of the per-query differences. A smaller difference is not "absent":
it is simply below the resolution of this many queries.
Source code in src/ranklens/stats/power.py
18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 | |
required_queries
¶
required_queries(
delta: float,
sd: float,
*,
alpha: float = 0.05,
power: float = 0.8,
) -> int
How many paired queries are needed to detect a difference of delta.
Never fewer than 2: below that no test can tell a difference from noise, even when
sd is 0.
Source code in src/ranklens/stats/power.py
39 40 41 42 43 44 45 46 47 48 49 50 | |
standard_deviation
¶
standard_deviation(deltas: Sequence[float]) -> float
Standard deviation of per-query differences, as the two functions above expect.
Source code in src/ranklens/stats/power.py
53 54 55 56 57 58 | |
ppi_compare
¶
ppi_compare(
proxy_a: MetricResult,
proxy_b: MetricResult,
gold_a: MetricResult,
gold_b: MetricResult,
*,
lam: float | None = None,
alpha: float = 0.05,
) -> PPIComparison
Difference B - A of one metric, measured on proxy judgements and corrected on gold.
proxy_* are the metric of each run under proxy judgements (all queries),
gold_* under gold judgements (a random sample of queries). Every gold query
must also be measured under proxy judgements. lam fixes the weight of the
proxy part: 0 is gold only, 1 is classic PPI; None estimates it (PPI++).
With fewer than two proxy-only queries the spread of the proxy part cannot be
measured, so the weight is 0 whatever lam says: an interval that ignores that
spread would be too narrow. Queries measured under proxy judgements in only one of
the runs are not used.
Source code in src/ranklens/stats/ppi.py
37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 | |