betapair
One question, answered:
We ran the same set of items before and after a change. Did anything really get better or worse, and which groups are responsible?
betapair takes paired pass/fail results (each item measured twice), works out
whether the change is real, and points at the groups that moved. It is small
and dependency-light: the maths uses only Python's built-in math and
random, and pydantic is used just to save reports as JSON.
What it is for
- Comparing two versions of a test, a model, a lesson or a prompt on the same items.
- Watching many groups at once — classes, models, datasets, customers.
- Not letting a good average hide a bad group: if one group gets much worse
while the rest improve,
betapairsays so. - Streaming: add results as they arrive and ask for a fresh report at any time.
Install
pip install betapair
# or with uv:
uv add betapair
From a checkout:
uv sync --extra dev
Count the changes
If you already have the totals, mcnemar() turns them into an answer.
from betapair import mcnemar
# 12 items went from fail to pass; 3 went from pass to fail.
result = mcnemar(n_improved=12, n_regressed=3)
print("chance it really improved:", round(result.p_improved, 4))
print("likely range :", round(result.eti_low, 3), "to", round(result.eti_high, 3))
chance it really improved: 0.9894
likely range : 0.544 to 0.927
p_improved is the chance the change is a genuine improvement rather than
luck. The range is where the true improvement rate most likely sits. Items that
did not change are ignored — only the ones that moved tell you about the
change.
Many groups at once
Usually you have several groups and several items. HealthTracker collects the
results and report() sums them up.
Show example
from betapair import HealthTracker
tracker = HealthTracker()
for q in range(10):
# group_a: many items got better
tracker.observe("group_a", "quiz", f"q{q}", baseline=(q >= 6), candidate=(q >= 2))
# group_b: some items got better
tracker.observe("group_b", "quiz", f"q{q}", baseline=(q >= 6), candidate=(q >= 4))
report = tracker.report()
print("score :", round(report.score, 1))
print("range :", round(report.score_low, 1), "to", round(report.score_high, 1))
print("baseline :", round(report.baseline_score, 1))
print("delta (pp) :", round(report.delta_pp, 2))
print("status :", report.status)
print("guardrail :", report.guardrail.status)
for c in report.contributions:
print(f" {c.stratum:8s} {c.verdict:10s} change={c.delta_pp_contribution:+5.1f}pp")
score : 70.0
range : 47.8 to 85.4
baseline : 40.0
delta (pp) : 30.0
status : improving
guardrail : ok
group_a improving change=+20.0pp
group_b stable change=+10.0pp
scoreis the pass rate after the change,baselinethe pass rate before, anddelta_ppthe change in percentage points.score_low/score_highare the credible bounds onscore: the sameBeta(1 + k, 1 + n - k)posterior whose mode is the headline number, over the weighted effective pairs, so the range shrinks as more items run and describes exactly the micro-average it belongs to.statusis the overall verdict:improving,stable,regressing,mixed, orinsufficient(not enough data yet).- Each contribution is one group. The
changevalues add up todelta_pp, so you can see exactly who moved the number. group_bisstableeven though it improved: the improvement is real but not yet strong enough on its own to call.
The first run, and new views
A paired score needs a before and an after. On the very first run there is
no before, so score is None and the status is insufficient — but the
candidate results already measured are not thrown away. score_unpaired
reports them as an absolute level, so a dashboard has something to show while
the baseline is still missing:
Show example
for q in range(10):
tracker.observe("group_a", "quiz", f"q{q}", baseline=None, candidate=q < 7)
report = tracker.report()
print(report.status) # insufficient - no comparison yet
print(report.score) # None - the paired score needs a baseline
print(report.score_unpaired) # 70.0 - the candidate epoch's own level
print(report.unpaired) # 10 rows behind it
The same applies to a view introduced mid-stream: it contributes
score_unpaired until its first baseline exists, and then joins the paired
score. score_unpaired is an absolute level only — it never enters
delta_pp, the verdict or the regression gate, so it can never be mistaken
for a comparison.
Catching a group that got worse
A good average can hide a broken group. betapair checks for that and names the
group.
Show example
from betapair import HealthTracker
tracker = HealthTracker()
for q in range(10):
tracker.observe("group_a", "quiz", f"q{q}", baseline=False, candidate=True) # better
tracker.observe("group_b", "quiz", f"q{q}", baseline=True, candidate=False) # worse
report = tracker.report()
print("score :", round(report.score, 1))
print("delta (pp):", round(report.delta_pp, 2))
print("status :", report.status)
print("guardrail :", report.guardrail.status, report.guardrail.flagged)
score : 50.0
delta (pp): 0.0
status : regressing
guardrail : heterogeneous ['group_b/quiz']
The average did not move at all, but group_b clearly got worse, so the report
is regressing and flagged names the culprit. This is the most important
safety feature.
Watching the score move over time
A single report says what the score is now. Across epochs, advance() keeps
one score per finished epoch, and score_band() turns that history into a
range: how much the score moves once sampling noise is taken out.
Show example
from betapair import HealthTracker
tracker = HealthTracker()
for rate in (0.70, 0.71, 0.69, 0.70):
for q in range(200):
tracker.observe("group_a", "quiz", f"q{q}", baseline=False, candidate=q < rate * 200)
tracker.advance()
band = tracker.score_band(window=3)
print("avg score :", round(band.center, 1))
print("likely range:", round(band.low, 1), "to", round(band.high, 1))
print("noise :", round(band.within_sd, 2), "pp")
print("movement :", round(band.between_sd, 2), "pp")
avg score : 70.0
likely range: 63.7 to 76.3
noise : 3.22 pp
movement : 0.0 pp
within_sd is the per-epoch sampling noise — it shrinks as more items run —
and between_sd is real epoch-to-epoch movement. When the whole observed
spread is explained by noise, between_sd is 0 and the band is just the
noise; a volatile metric widens it. window=3 keeps the band responsive to a
recent regime change. A series can also be passed to score_band() directly,
as percentages or as reports:
Show example
from betapair import score_band
band = score_band([70.0, 72.0, 69.5, 85.0])
print(round(band.low, 1), "to", round(band.high, 1)) # 59.8 to 88.5
You can record a point without rolling the epoch with tracker.record_score(),
and the timeline survives to_json()/from_json().
See which thresholds the report used. Every decision threshold has a
hand-set default, and the report publishes the resolved set. Passing the
tracker's own timeline as calibration history lets the report size those
thresholds from the measured series rather than a fixed constant:
See which thresholds the report used
report = tracker.report(calibrate=True)
print(report.calibration.rope_pp) # the resolved ROPE, in pp
print(report.calibration.derived) # thresholds taken from the series
Three thresholds are sized this way. The ROPE becomes the metric's own
between_sd once three epochs are on record (a move smaller than the metric's
normal epoch-to-epoch movement is not a change), so volatile views stop reading
as improving on ordinary noise. The regression prior is re-centred on the
base rate of true regressions the previous epoch measured — recorded on each
timeline point as pi_mean — and clipped away from 0 and 1 so it cannot
collapse. The coverage gate becomes the epochs' observed unresolved share
plus a margin (recorded as unresolved_share), so a pipeline that normally
resolves almost everything is not waved through at 25% pending — and a cold
start, where nearly everything is unresolved, keeps the default until enough
epochs exist to size it. Calibration is opt-in and additive: with no history —
or with calibrate left off — every number is unchanged, and a threshold is
only taken from the series when there is enough history to measure it.
The seeded operating characteristics behind those three thresholds — verdict
stability as volatility changes, the prior's power against the measured base
rate, and the gate's behaviour on a healthy vs stalled pipeline — are
generated into docs/validation.md by
uv run python tools/calibration.py --write.
Everyday extras
Add many results at once with PairRecord:
Add many results at once
from betapair import HealthTracker, PairRecord
tracker = HealthTracker()
tracker.observe_many(
[
PairRecord("group_a", "quiz", "q1", False, True), # fail -> pass
PairRecord("group_a", "quiz", "q2", True, True), # stayed passing
]
)
Items with no result yet. Use None; they are counted as pending and
left out, so a half-finished run never looks like a regression:
Items with no result yet
tracker.observe("group_a", "quiz", "q3", baseline=False, candidate=None)
print(tracker.pending) # 1
A failed evaluation. Attribute it to whoever failed. ERROR means the
harness could not evaluate the item (a worker died, an API returned 5xx): it
is genuinely unresolved, so it is left out of the score and gates coverage.
A failed evaluation
from betapair import ERROR
tracker.observe("group_a", "quiz", "q4", baseline=False, candidate=ERROR)
print(tracker.errored) # 1
FAILED means the view could not answer the item (its SQL timed out, its
query OOM'd the warehouse, its output crashed the judge). A view-caused failure
is a result, so it is scored as a failure and stays in the score and the paired
test — a view cannot raise its own level by breaking on the questions it was
failing:
Show example
from betapair import FAILED
tracker.observe("group_a", "quiz", "q6", baseline=True, candidate=FAILED)
print(tracker.failed) # 1, counted against the score rather than dropped from it
A question leaves the bank. Retire it, so the score is not quietly redefined by the ones that remain:
A question leaves the bank
tracker.retire("group_a", "quiz", "q5")
print(tracker.retired) # 1
report = tracker.report()
print(report.score) # the surviving questions only
print(report.score_held) # ...with the retired ones held at their last result
print(report.composition_effect_pp) # how much of the score is the bank shrinking
A retired question is not pending (nothing is outstanding) and not ERROR
(the harness did not fail), so it does not gate coverage. composition_effect_pp
is the part of the score explained by questions leaving the bank rather than
by the views changing, so deleting the questions you were failing shows up as a
number instead of an improvement. (Erroring out of them with FAILED needs no
such bookkeeping: the failure is already in the score.)
Name questions consistently. A row is identified by
(group, outcome, item), but an item is identified globally by
(outcome, item). Two views that use the same item string are recorded as
seeing the same question — that overlap is what lets a difficulty model
compare their levels later — so use one stable name per question across views
and epochs. A renamed question is a different item:
Name questions consistently
from betapair import item_key
item_key("quiz", "q5") # ('quiz', 'q5')
The resolved rows are available under that key with tracker.item_responses();
pending, ERROR and retired rows are omitted.
Comparing views on different question banks
score is a pass rate on whatever questions each view happened to run. If
group_b ran an easier bank than group_a, it looks healthier even when the
two views are equally good. adjust_difficulty=True fits a latent difficulty
model (Rasch/IRT) to the candidate responses and reports the level each view
would score on a common reference bank, so the two become comparable:
Show example
from betapair import HealthTracker
tracker = HealthTracker(adjust_difficulty=True)
for q in range(4): # shared anchors: both views pass
tracker.observe("group_a", "quiz", f"anchor{q}", baseline=False, candidate=True)
tracker.observe("group_b", "quiz", f"anchor{q}", baseline=False, candidate=True)
for q in range(8): # group_a ran a hard bank and is still failing it
tracker.observe("group_a", "quiz", f"hard{q}", baseline=False, candidate=False)
for q in range(8): # group_b ran an easy bank and passes all of it
tracker.observe("group_b", "quiz", f"easy{q}", baseline=False, candidate=True)
report = tracker.report()
print("raw :", round(report.score, 1))
print("adjusted :", round(report.score_adjusted, 1))
print("shift (pp):", round(report.difficulty_adjustment_pp, 1))
raw : 66.7
adjusted : 63.3
shift (pp): -3.3
score_adjustedis the same weighted micro-average asscore, but over each view's equated rate, andscore_adjusted_low/score_adjusted_highare its interval. The rawscoreis never replaced: both numbers are always present, anddifficulty_adjustment_ppis the difference between them.- The paired
delta_ppis not adjusted — both epochs run the same items, so difficulty already cancels in the discordant pairs. Only the level needs equating. - It is opt-in. With
adjust_difficultyoff (the default) every number is bit-identical to a report built without it.
Pin the reference bank. By default the reference bank is every item observed, which shrinks as questions are retired and shifts what the adjusted score means. A named, versioned bank keeps the number comparable across epochs:
Pin the reference bank
from betapair import HealthTracker, ReferenceBank
bank = ReferenceBank.from_responses(tracker.item_responses(), name="standard", version="2026-07")
tracker = HealthTracker(adjust_difficulty=True, reference_bank=bank)
report.irt.reference publishes which bank was used — the declared name and
version, a content digest of the items actually used, and the declared vs
available counts — so two epochs are only comparable when their digests
match. The bank is saved with the tracker's snapshot, so a resumed tracker
scores the same way.
Read the linking caveat. Views are connected only through items they both
ran. report.irt reports connected and weakest_link (the fewest shared
items over any view pair), plus prior_dominated for views whose responses
were perfectly separated. With no shared items the fit still runs — the priors
bridge the banks — but that is an assumption, not a measurement.
The seeded evidence behind the adjusted interval's coverage, the cost of weak
linking, and how much bank bias equating removes is generated into
docs/validation.md by tools/calibration.py.
Some groups matter more. Tag them and weight the tags:
Some groups matter more
tracker = HealthTracker(
categories={"group_a": "tier-1"},
category_weights={"tier-1": 3.0, "tier-2": 1.0},
)
Which groups share a dependency. A group here is the second label a view can carry: the upstream thing it depends on — a source table, a domain, a metric family. Twenty views on one table are not twenty independent tests, so the regression gate reads the whole group as one test and reports an incident instead of a flag storm:
Which groups share a dependency
tracker = HealthTracker(groups={"sv_orders": "orders_table", "sv_lines": "orders_table"})
tracker.set_group("sv_lines", "orders_table") # or bind it later
tracker.group_of("sv_orders") # "orders_table"
report.groups
# [GroupSummary(group='orders_table', views=2, cells=8, p_incident=0.98, flagged=True, ...)]
The group changes nothing about the score or the delta, and no individual view
is hidden: the per-cell gate still runs, so a single regressing view is flagged
whether or not its group is. Each GroupSummary carries its additive share of
the layer's movement (delta_pp_contribution), and the per-cell
contributions (each tagged with group) expand an incident into the views
responsible — the same contribution decomposition used to investigate a
downstream metric. Groups are persisted in the snapshot exactly like
categories.
Start a new round. advance() moves the "after" results into "before" and
clears the slate:
Start a new round
tracker.advance(candidate_epoch="round_2")
Save and load. State is plain JSON, including the random seed, so a restored tracker gives the same numbers for the same data:
Save and load
text = tracker.to_json()
tracker = HealthTracker.from_json(text)
Common terms and what they mean
| Term | What it means |
|---|---|
| Pair | One item measured twice: before and after. |
| Changed item | A pair that went fail→pass or pass→fail. Items that stayed the same carry no signal and are ignored. |
| Switch rate | Of the changed items, the fraction that improved. Above 0.5 means more got better than worse. |
p_improved |
The chance the change is a real improvement. |
| Credible interval | A range of likely values for a number, given the data (shown as "likely range"). |
| Bayes factor | How much more likely the data are under "improved" than under "no change". |
mu |
The model's overall switch rate across all groups. |
kappa |
How much the groups move together; big means they agree. |
| Guardrail | A check that stops a good average from hiding a bad group. |
| Tier / weight | A label saying a group matters more, so it counts more in the score and the verdict. |
| Group | A label naming the upstream dependency a view shares with others. The regression gate reads the group as one test, so a shared break is one incident, not a flag storm; it never changes the score. |
| Incident | One flagged group: the shared dependency broke and its views regressed together. Reported in report.groups, with each view's own contribution still in report.contributions. |
| Equated score | The level a view would score on a common reference bank, so views that ran different questions can be compared. |
| Reference bank | The set of questions an adjusted score is measured on; pin it (named and versioned) so the number keeps its meaning. |
What it cannot do
- It only understands pass/fail, not "how wrong" an answer was.
- Small samples can only catch big changes.
- Cells in the same group are pooled into one test only if you say so; two views on the same table that you leave ungrouped are still treated as independent and will look slightly more certain than they are.
- The difficulty-adjusted score compares views only through questions they both
ran; banks with no overlap are bridged by an assumption (the priors), which
report.irtflags asconnected=Falserather than measuring. - If too much of the comparison has no result yet, the status is
insufficientrather than a guess.
Development
uv sync --extra dev # set up the environment
uv run pytest # run the tests
uv run ruff check . # lint
uv run ruff format . # format
uv run mypy # type-check
The seeded operating characteristics in
docs/validation.md
are regenerated with uv run python tools/calibration.py --write. CI runs
uv run python tools/calibration.py --check, which fails with a diff if that
document is stale, so a statistical change and its evidence land together.
License
Apache-2.0 — see LICENSE.
Metadata
Release files for betapair 0.1.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | Provenance | Actions |
|---|---|---|---|---|
| betapair-0.1.0.tar.gz | 276.6 kB | With provenance | Details for betapair-0.1.0.tar.gz |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Actions Reset |
|---|---|---|---|---|
| betapair-0.1.0-py3-none-any.whl | Python 3 | none | any | Details for betapair-0.1.0-py3-none-any.whl |
Total release size: 380.3 kB
Release files / betapair-0.1.0.tar.gz
| Download URL | betapair-0.1.0.tar.gz |
|---|---|
| Size | 276.6 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
65ccb9e6eb268e3896c1718ad182587f0dd42f0c73b6fa2a3e56e53aacacd44c
|
|
BLAKE2b-256 checksum How to use checksums |
e00637328615118fa0a07dcff92b2d42616efd23aa4943ed2256736adbd36533
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logRelease files / betapair-0.1.0-py3-none-any.whl
| Download URL | betapair-0.1.0-py3-none-any.whl |
|---|---|
| Size | 103.6 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
b9e70bb9de675f43144b10395a1ca6bfec9b318a2c9ecdc59f1aa0b19672bafb
|
|
BLAKE2b-256 checksum How to use checksums |
d1d410f29d159d665ff06d7a9d4412b12499471a215b4830d8f07cdc0469edee
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.16
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logSecurity for betapair 0.1.0
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
Full provenance
All 2 files in this release have been uploaded with provenance metadata.
Attestation for betapair-0.1.0.tar.gz
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Oct 26, 2026.
Transparency logA note on package security
PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.
To learn more about protecting yourself from malicious actors, visit our package security documentation.