bayesian-pv-census 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bayesian_pv_census/__init__.py +50 -0
- bayesian_pv_census/aggregation.py +168 -0
- bayesian_pv_census/audit.py +230 -0
- bayesian_pv_census/budgeting.py +93 -0
- bayesian_pv_census/correction.py +276 -0
- bayesian_pv_census/data/france_2024.csv +94 -0
- bayesian_pv_census/datatypes.py +159 -0
- bayesian_pv_census/figures.py +108 -0
- bayesian_pv_census/io.py +163 -0
- bayesian_pv_census-0.1.0.dist-info/METADATA +233 -0
- bayesian_pv_census-0.1.0.dist-info/RECORD +13 -0
- bayesian_pv_census-0.1.0.dist-info/WHEEL +4 -0
- bayesian_pv_census-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""bayesian_pv_census -- turn a detector's output into a census, then audit a register against it.
|
|
2
|
+
|
|
3
|
+
A detector's raw count is not a measurement: it misses objects and invents
|
|
4
|
+
others, at rates that vary from place to place. Given a validation sample,
|
|
5
|
+
this package turns that raw count into a posterior over the true quantity, and
|
|
6
|
+
turns the posterior into a verdict on whatever register claims to record it.
|
|
7
|
+
|
|
8
|
+
Nothing here knows about photovoltaics, France, or geometry. A "unit" is
|
|
9
|
+
anything with a raw total and a validation sample, and ``raw`` is whatever
|
|
10
|
+
quantity you chose to count -- installed capacity, number of installations,
|
|
11
|
+
roof area. The package ships the paper's French data as a demo; see
|
|
12
|
+
:func:`load_demo`.
|
|
13
|
+
|
|
14
|
+
Reference implementation of the statistical core of:
|
|
15
|
+
Kasmi, G. et al. (2026). Joule.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from .datatypes import JEFFREYS, AuditFlag, BetaPrior, CorrectionResult, UnitRecord
|
|
19
|
+
from .correction import correct_batch, correct_unit, fit_empirical_bayes_prior
|
|
20
|
+
from .audit import (
|
|
21
|
+
BatteryResult,
|
|
22
|
+
compare_reference_variants,
|
|
23
|
+
conversion_threshold,
|
|
24
|
+
evaluate,
|
|
25
|
+
evaluate_batch,
|
|
26
|
+
run_battery,
|
|
27
|
+
)
|
|
28
|
+
from .budgeting import Budget, half_width_closed_form, required_sample_size
|
|
29
|
+
from .aggregation import compare_aggregation_scales, pooling_penalty
|
|
30
|
+
from .io import (
|
|
31
|
+
has_reported_values,
|
|
32
|
+
load_demo,
|
|
33
|
+
read_units_csv,
|
|
34
|
+
units_from_dataframe,
|
|
35
|
+
units_to_dataframe,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
__version__ = "0.1.0"
|
|
39
|
+
|
|
40
|
+
__all__ = [
|
|
41
|
+
"UnitRecord", "BetaPrior", "CorrectionResult", "AuditFlag", "JEFFREYS",
|
|
42
|
+
"correct_unit", "correct_batch", "fit_empirical_bayes_prior",
|
|
43
|
+
"evaluate", "evaluate_batch", "run_battery", "BatteryResult",
|
|
44
|
+
"conversion_threshold", "compare_reference_variants",
|
|
45
|
+
"required_sample_size", "half_width_closed_form", "Budget",
|
|
46
|
+
"compare_aggregation_scales", "pooling_penalty",
|
|
47
|
+
"read_units_csv", "units_from_dataframe", "units_to_dataframe",
|
|
48
|
+
"load_demo", "has_reported_values",
|
|
49
|
+
"__version__",
|
|
50
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""What forming the correction factor at a coarser scale costs.
|
|
2
|
+
|
|
3
|
+
Correcting each unit and summing is not the same as pooling the units' rates
|
|
4
|
+
and correcting once. The gap is exact and signed, and it has a closed second-
|
|
5
|
+
order form -- see :func:`pooling_penalty`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
from .correction import (
|
|
16
|
+
DEFAULT_N_DRAWS,
|
|
17
|
+
DEFAULT_SEED,
|
|
18
|
+
_seed_for,
|
|
19
|
+
_resolve_priors,
|
|
20
|
+
fit_empirical_bayes_prior,
|
|
21
|
+
)
|
|
22
|
+
from .datatypes import UnitRecord
|
|
23
|
+
|
|
24
|
+
__all__ = ["pooled_factor_draws", "compare_aggregation_scales", "pooling_penalty",
|
|
25
|
+
"AggregationComparison"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class AggregationComparison:
|
|
30
|
+
totals: pd.DataFrame
|
|
31
|
+
draws: dict[str, np.ndarray] = field(repr=False, default_factory=dict)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def pooled_factor_draws(
|
|
35
|
+
units: list[UnitRecord],
|
|
36
|
+
weights: np.ndarray,
|
|
37
|
+
prior,
|
|
38
|
+
n_draws: int = DEFAULT_N_DRAWS,
|
|
39
|
+
seed: int = DEFAULT_SEED,
|
|
40
|
+
key: str = "pooled",
|
|
41
|
+
) -> np.ndarray:
|
|
42
|
+
"""One correction factor for a whole group: mean of drawn P over mean of drawn R.
|
|
43
|
+
|
|
44
|
+
Weights are the units' raw quantities, normalised. Weighting by annotation
|
|
45
|
+
sample size instead would be a different and much worse estimator: how many
|
|
46
|
+
points were annotated in a unit has nothing to do with that unit's share of
|
|
47
|
+
the quantity being corrected.
|
|
48
|
+
"""
|
|
49
|
+
prior_p, prior_r = _resolve_priors(prior, None)
|
|
50
|
+
w = np.asarray(weights, dtype=float)
|
|
51
|
+
w = w / w.sum()
|
|
52
|
+
|
|
53
|
+
a_p = np.array([prior_p.alpha + u.precision_tp for u in units])
|
|
54
|
+
b_p = np.array([prior_p.beta + u.precision_fp for u in units])
|
|
55
|
+
a_r = np.array([prior_r.alpha + u.recall_tp for u in units])
|
|
56
|
+
b_r = np.array([prior_r.beta + u.recall_fn for u in units])
|
|
57
|
+
|
|
58
|
+
rng = np.random.default_rng(_seed_for(key, seed))
|
|
59
|
+
p = rng.beta(a_p, b_p, size=(n_draws, len(units))) @ w
|
|
60
|
+
r = rng.beta(a_r, b_r, size=(n_draws, len(units))) @ w
|
|
61
|
+
return np.divide(p, r, out=np.full(n_draws, np.nan), where=r > 0)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def pooling_penalty(units: list[UnitRecord], weights: np.ndarray | None = None) -> dict:
|
|
65
|
+
"""Second-order estimate of the relative cost of pooling a group.
|
|
66
|
+
|
|
67
|
+
(F_pooled / f_unit) - 1 ~= -[ CV(R)^2 - rho * CV(P) * CV(R) ]
|
|
68
|
+
|
|
69
|
+
where the coefficients of variation and the correlation are taken across the
|
|
70
|
+
units being merged, weighted by their raw quantities. They describe the
|
|
71
|
+
heterogeneity of the population, not the sampling uncertainty of the rates.
|
|
72
|
+
|
|
73
|
+
The first term is the cost of recall heterogeneity and is always a penalty,
|
|
74
|
+
because 1/R is convex: pooling always yields a smaller factor and therefore
|
|
75
|
+
a smaller corrected total. The second offsets it when the units that are
|
|
76
|
+
well detected are well detected on both metrics. If every unit shares the
|
|
77
|
+
same recall, the penalty is exactly zero however dispersed precision is.
|
|
78
|
+
"""
|
|
79
|
+
p = np.array(
|
|
80
|
+
[u.precision_tp / (u.precision_tp + u.precision_fp) for u in units]
|
|
81
|
+
)
|
|
82
|
+
r = np.array([u.recall_tp / (u.recall_tp + u.recall_fn) for u in units])
|
|
83
|
+
w = np.asarray(
|
|
84
|
+
[u.raw for u in units] if weights is None else weights, dtype=float
|
|
85
|
+
)
|
|
86
|
+
w = w / w.sum()
|
|
87
|
+
|
|
88
|
+
mp, mr = float(w @ p), float(w @ r)
|
|
89
|
+
sp = float(np.sqrt(w @ (p - mp) ** 2))
|
|
90
|
+
sr = float(np.sqrt(w @ (r - mr) ** 2))
|
|
91
|
+
cov = float(w @ ((p - mp) * (r - mr)))
|
|
92
|
+
rho = cov / (sp * sr) if sp > 0 and sr > 0 else 0.0
|
|
93
|
+
cv_p, cv_r = sp / mp, sr / mr
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
"cv_precision": cv_p,
|
|
97
|
+
"cv_recall": cv_r,
|
|
98
|
+
"rho": rho,
|
|
99
|
+
"penalty": -(cv_r**2 - rho * cv_p * cv_r),
|
|
100
|
+
"penalty_recall_only": -(cv_r**2),
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def compare_aggregation_scales(
|
|
105
|
+
units: list[UnitRecord],
|
|
106
|
+
groups: dict[str, list[str]] | None = None,
|
|
107
|
+
*,
|
|
108
|
+
prior="jeffreys",
|
|
109
|
+
n_draws: int = DEFAULT_N_DRAWS,
|
|
110
|
+
seed: int = DEFAULT_SEED,
|
|
111
|
+
) -> AggregationComparison:
|
|
112
|
+
"""Total under three ways of forming the correction factor.
|
|
113
|
+
|
|
114
|
+
Unit level (one factor per unit, then sum) is the reference and, by
|
|
115
|
+
linearity of expectation, reproduces the sum of per-unit means exactly.
|
|
116
|
+
Group level pools within each group. Full pool uses a single factor.
|
|
117
|
+
|
|
118
|
+
``groups`` maps a group label to a list of unit ids. Omit it to compare only
|
|
119
|
+
unit level against the full pool.
|
|
120
|
+
"""
|
|
121
|
+
from .correction import correct_unit
|
|
122
|
+
|
|
123
|
+
if prior == "empirical_bayes":
|
|
124
|
+
pp, pr, _ = fit_empirical_bayes_prior(units)
|
|
125
|
+
prior = (pp, pr)
|
|
126
|
+
|
|
127
|
+
by_id = {u.unit_id: u for u in units}
|
|
128
|
+
draws: dict[str, np.ndarray] = {}
|
|
129
|
+
|
|
130
|
+
unit_total = np.zeros(n_draws)
|
|
131
|
+
for u in units:
|
|
132
|
+
unit_total += correct_unit(
|
|
133
|
+
u, prior=prior, n_draws=n_draws, seed=seed
|
|
134
|
+
).draws
|
|
135
|
+
draws["unit"] = unit_total
|
|
136
|
+
|
|
137
|
+
if groups:
|
|
138
|
+
group_total = np.zeros(n_draws)
|
|
139
|
+
for gname, ids in groups.items():
|
|
140
|
+
members = [by_id[i] for i in ids if i in by_id]
|
|
141
|
+
if not members:
|
|
142
|
+
continue
|
|
143
|
+
w = np.array([m.raw for m in members])
|
|
144
|
+
factor = pooled_factor_draws(
|
|
145
|
+
members, w, prior, n_draws=n_draws, seed=seed, key=gname
|
|
146
|
+
)
|
|
147
|
+
group_total += w.sum() * factor
|
|
148
|
+
draws["group"] = group_total
|
|
149
|
+
|
|
150
|
+
w_all = np.array([u.raw for u in units])
|
|
151
|
+
draws["pooled"] = w_all.sum() * pooled_factor_draws(
|
|
152
|
+
units, w_all, prior, n_draws=n_draws, seed=seed, key="__all__"
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
ref = float(np.nanmean(draws["unit"]))
|
|
156
|
+
rows = []
|
|
157
|
+
for level, d in draws.items():
|
|
158
|
+
lo, hi = np.nanquantile(d, [0.025, 0.975])
|
|
159
|
+
rows.append(
|
|
160
|
+
{
|
|
161
|
+
"level": level,
|
|
162
|
+
"mean": float(np.nanmean(d)),
|
|
163
|
+
"ci95_low": float(lo),
|
|
164
|
+
"ci95_high": float(hi),
|
|
165
|
+
"gap_vs_unit": float(np.nanmean(d)) / ref - 1,
|
|
166
|
+
}
|
|
167
|
+
)
|
|
168
|
+
return AggregationComparison(totals=pd.DataFrame(rows), draws=draws)
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Compare a reference value against the corrected posterior, and across specifications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
from .correction import DEFAULT_MIN_WEIGHTED_N, DEFAULT_N_DRAWS, DEFAULT_SEED, correct_batch
|
|
9
|
+
from .datatypes import AuditFlag, CorrectionResult, UnitRecord
|
|
10
|
+
|
|
11
|
+
__all__ = ["evaluate", "evaluate_batch", "run_battery", "BatteryResult",
|
|
12
|
+
"conversion_threshold", "compare_reference_variants"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def evaluate(
|
|
16
|
+
result: CorrectionResult,
|
|
17
|
+
reported_value: float | None = None,
|
|
18
|
+
level: float = 0.99,
|
|
19
|
+
) -> AuditFlag:
|
|
20
|
+
"""Where does the reported value fall relative to the corrected posterior?
|
|
21
|
+
|
|
22
|
+
``below`` means the reference is under the lower bound: capacity is missing
|
|
23
|
+
from it. ``above`` means the reference exceeds the upper bound. ``within``
|
|
24
|
+
means the two are compatible at this credible level.
|
|
25
|
+
|
|
26
|
+
The interval is the only thing that decides. A large gap inside a wide
|
|
27
|
+
interval is not a finding; a small gap outside a tight one is.
|
|
28
|
+
"""
|
|
29
|
+
if reported_value is None:
|
|
30
|
+
raise ValueError(f"{result.unit_id}: no registry value to evaluate against")
|
|
31
|
+
lo, hi = result.ci(level)
|
|
32
|
+
if reported_value < lo:
|
|
33
|
+
status = "below"
|
|
34
|
+
elif reported_value > hi:
|
|
35
|
+
status = "above"
|
|
36
|
+
else:
|
|
37
|
+
status = "within"
|
|
38
|
+
return AuditFlag(
|
|
39
|
+
unit_id=result.unit_id,
|
|
40
|
+
status=status,
|
|
41
|
+
reported_value=float(reported_value),
|
|
42
|
+
corrected_mean=result.mean,
|
|
43
|
+
ci=(lo, hi),
|
|
44
|
+
level=level,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def evaluate_batch(
|
|
49
|
+
results: list[CorrectionResult],
|
|
50
|
+
units: list[UnitRecord],
|
|
51
|
+
level: float = 0.99,
|
|
52
|
+
) -> pd.DataFrame:
|
|
53
|
+
"""Evaluate every unit that carries a reference value."""
|
|
54
|
+
rows = []
|
|
55
|
+
for res, u in zip(results, units):
|
|
56
|
+
if u.reported_value is None:
|
|
57
|
+
continue
|
|
58
|
+
f = evaluate(res, u.reported_value, level=level)
|
|
59
|
+
rows.append(
|
|
60
|
+
{
|
|
61
|
+
"unit_id": f.unit_id,
|
|
62
|
+
"status": f.status,
|
|
63
|
+
"reported_value": f.reported_value,
|
|
64
|
+
"corrected_mean": f.corrected_mean,
|
|
65
|
+
"ci_low": f.ci[0],
|
|
66
|
+
"ci_high": f.ci[1],
|
|
67
|
+
"gap": f.gap,
|
|
68
|
+
"gap_pct": f.gap_pct,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return pd.DataFrame(rows)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def conversion_threshold(
|
|
75
|
+
result: CorrectionResult,
|
|
76
|
+
reported_value: float,
|
|
77
|
+
direction: str,
|
|
78
|
+
level: float = 0.99,
|
|
79
|
+
retained_scale: float = 1.0,
|
|
80
|
+
) -> float:
|
|
81
|
+
"""Scale factor at which this unit's flag would flip, in closed form.
|
|
82
|
+
|
|
83
|
+
Rescaling every detected quantity by a constant (a different surface-to-power
|
|
84
|
+
coefficient, a different filtering threshold) multiplies the posterior and
|
|
85
|
+
both its bounds, while the reference value stays put. The status is therefore
|
|
86
|
+
monotone in that constant and flips at a single point, with no numerical
|
|
87
|
+
sweep needed.
|
|
88
|
+
|
|
89
|
+
The two directions are mirror images. A unit flagged ``below`` loses its flag
|
|
90
|
+
when the scale *falls* to ``retained_scale * registry / CI_low``; a unit
|
|
91
|
+
flagged ``above`` loses it when the scale *rises* to
|
|
92
|
+
``retained_scale * registry / CI_high``.
|
|
93
|
+
"""
|
|
94
|
+
lo, hi = result.ci(level)
|
|
95
|
+
if direction == "below":
|
|
96
|
+
return float(retained_scale * reported_value / lo)
|
|
97
|
+
if direction == "above":
|
|
98
|
+
return float(retained_scale * reported_value / hi)
|
|
99
|
+
raise ValueError("direction must be 'below' or 'above'")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class BatteryResult:
|
|
103
|
+
"""Flags for every unit under every specification."""
|
|
104
|
+
|
|
105
|
+
def __init__(self, flags: pd.DataFrame, tables: dict[str, pd.DataFrame]):
|
|
106
|
+
self.flags = flags # unit_id x spec -> status
|
|
107
|
+
self.tables = tables # spec -> full correction table
|
|
108
|
+
|
|
109
|
+
def concordance_table(self, spec_a: str = "A", spec_b: str = "B") -> pd.DataFrame:
|
|
110
|
+
"""Crosstab of two specifications' verdicts.
|
|
111
|
+
|
|
112
|
+
The diagonal cells define the hard core. The two off-diagonal corners
|
|
113
|
+
(below/above and above/below) are the substantive check: if they are
|
|
114
|
+
empty, no unit changes sign between specifications, and the disagreement
|
|
115
|
+
is only ever about the boundary with ``within``.
|
|
116
|
+
"""
|
|
117
|
+
order = ["below", "within", "above"]
|
|
118
|
+
out = pd.crosstab(self.flags[spec_a], self.flags[spec_b])
|
|
119
|
+
return out.reindex(index=order, columns=order, fill_value=0)
|
|
120
|
+
|
|
121
|
+
def hard_core(self, status: str = "below") -> list[str]:
|
|
122
|
+
"""Units flagged ``status`` by every specification in the battery."""
|
|
123
|
+
cols = [c for c in self.flags.columns if c != "unit_id"]
|
|
124
|
+
mask = np.logical_and.reduce([self.flags[c] == status for c in cols])
|
|
125
|
+
return self.flags.loc[mask, "unit_id"].tolist()
|
|
126
|
+
|
|
127
|
+
def negative_control(self) -> list[str]:
|
|
128
|
+
"""Units unanimously flagged in the opposite direction.
|
|
129
|
+
|
|
130
|
+
A method that manufactured discrepancies would produce these in numbers
|
|
131
|
+
comparable to the hard core. They are the control group.
|
|
132
|
+
"""
|
|
133
|
+
return self.hard_core(status="above")
|
|
134
|
+
|
|
135
|
+
def summary(self) -> pd.DataFrame:
|
|
136
|
+
cols = [c for c in self.flags.columns if c != "unit_id"]
|
|
137
|
+
return pd.DataFrame(
|
|
138
|
+
{c: self.flags[c].value_counts() for c in cols}
|
|
139
|
+
).reindex(["below", "within", "above"]).fillna(0).astype(int)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def run_battery(
|
|
143
|
+
units: list[UnitRecord],
|
|
144
|
+
specs: dict[str, dict] | None = None,
|
|
145
|
+
*,
|
|
146
|
+
prior="jeffreys",
|
|
147
|
+
level: float = 0.99,
|
|
148
|
+
min_weighted_n: int | None = DEFAULT_MIN_WEIGHTED_N,
|
|
149
|
+
n_draws: int = DEFAULT_N_DRAWS,
|
|
150
|
+
seed: int = DEFAULT_SEED,
|
|
151
|
+
) -> BatteryResult:
|
|
152
|
+
"""Run the audit under several specifications and cross-tabulate the verdicts.
|
|
153
|
+
|
|
154
|
+
``specs`` maps a label to a keyword dict. Two keys are understood:
|
|
155
|
+
``weighted`` (bool, specification B) and ``scale`` (float, a multiplicative
|
|
156
|
+
rescaling of every raw quantity -- the paper's specification C, expressed
|
|
157
|
+
generically rather than as a surface-to-power coefficient).
|
|
158
|
+
|
|
159
|
+
The default battery is the paper's: A on counts, B capacity-weighted.
|
|
160
|
+
"""
|
|
161
|
+
if specs is None:
|
|
162
|
+
specs = {"A": {"weighted": False}, "B": {"weighted": True}}
|
|
163
|
+
|
|
164
|
+
flags = pd.DataFrame({"unit_id": [u.unit_id for u in units]})
|
|
165
|
+
tables: dict[str, pd.DataFrame] = {}
|
|
166
|
+
|
|
167
|
+
for label, cfg in specs.items():
|
|
168
|
+
scale = cfg.get("scale", 1.0)
|
|
169
|
+
scaled = units
|
|
170
|
+
if scale != 1.0:
|
|
171
|
+
scaled = [
|
|
172
|
+
UnitRecord(**{**u.__dict__, "raw": u.raw * scale})
|
|
173
|
+
for u in units
|
|
174
|
+
]
|
|
175
|
+
df, results = correct_batch(
|
|
176
|
+
scaled,
|
|
177
|
+
prior=prior,
|
|
178
|
+
weighted=cfg.get("weighted", False),
|
|
179
|
+
min_weighted_n=min_weighted_n,
|
|
180
|
+
n_draws=n_draws,
|
|
181
|
+
seed=seed,
|
|
182
|
+
return_results=True,
|
|
183
|
+
)
|
|
184
|
+
tables[label] = df
|
|
185
|
+
status = []
|
|
186
|
+
for res, u in zip(results, units):
|
|
187
|
+
status.append(
|
|
188
|
+
evaluate(res, u.reported_value, level=level).status
|
|
189
|
+
if u.reported_value is not None
|
|
190
|
+
else None
|
|
191
|
+
)
|
|
192
|
+
flags[label] = status
|
|
193
|
+
|
|
194
|
+
return BatteryResult(flags=flags, tables=tables)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def compare_reference_variants(
|
|
198
|
+
results: list[CorrectionResult],
|
|
199
|
+
units: list[UnitRecord],
|
|
200
|
+
variant_a: dict[str, float],
|
|
201
|
+
variant_b: dict[str, float],
|
|
202
|
+
level: float = 0.99,
|
|
203
|
+
names: tuple[str, str] = ("a", "b"),
|
|
204
|
+
) -> pd.DataFrame:
|
|
205
|
+
"""Audit the same corrected estimate against two versions of a reference.
|
|
206
|
+
|
|
207
|
+
Useful whenever a register is published in more than one form -- truncated
|
|
208
|
+
and complete, provisional and consolidated -- to separate what the reference
|
|
209
|
+
does from what the audit does. The corrected estimate is held fixed, so any
|
|
210
|
+
change in verdict belongs to the reference.
|
|
211
|
+
"""
|
|
212
|
+
na, nb = names
|
|
213
|
+
rows = []
|
|
214
|
+
for res, u in zip(results, units):
|
|
215
|
+
va, vb = variant_a.get(u.unit_id), variant_b.get(u.unit_id)
|
|
216
|
+
if va is None or vb is None:
|
|
217
|
+
continue
|
|
218
|
+
sa = evaluate(res, va, level=level).status
|
|
219
|
+
sb = evaluate(res, vb, level=level).status
|
|
220
|
+
rows.append(
|
|
221
|
+
{
|
|
222
|
+
"unit_id": u.unit_id,
|
|
223
|
+
f"value_{na}": va,
|
|
224
|
+
f"value_{nb}": vb,
|
|
225
|
+
f"status_{na}": sa,
|
|
226
|
+
f"status_{nb}": sb,
|
|
227
|
+
"changed": sa != sb,
|
|
228
|
+
}
|
|
229
|
+
)
|
|
230
|
+
return pd.DataFrame(rows)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""How many annotations does a target precision buy, and vice versa.
|
|
2
|
+
|
|
3
|
+
The delta method on ``log(P/R)`` gives a closed form for the credible interval's
|
|
4
|
+
half-width, which inverts into a sample-size rule. Planning the annotation
|
|
5
|
+
effort therefore needs no simulation, only a guess at the detector's precision
|
|
6
|
+
and recall.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from scipy.stats import norm
|
|
15
|
+
|
|
16
|
+
__all__ = ["half_width_closed_form", "required_sample_size", "Budget"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class Budget:
|
|
21
|
+
n_precision: int
|
|
22
|
+
n_recall: int
|
|
23
|
+
achieved_half_width: float
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def total(self) -> int:
|
|
27
|
+
return self.n_precision + self.n_recall
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def half_width_closed_form(
|
|
31
|
+
precision: float,
|
|
32
|
+
recall: float,
|
|
33
|
+
n_precision: float,
|
|
34
|
+
n_recall: float,
|
|
35
|
+
level: float = 0.99,
|
|
36
|
+
) -> float:
|
|
37
|
+
"""Relative half-width of the credible interval on the correction factor.
|
|
38
|
+
|
|
39
|
+
h = z * sqrt( (1-P)/(P*n_P) + (1-R)/(R*n_R) )
|
|
40
|
+
|
|
41
|
+
The two terms are the sampling variance of ``log P`` and of ``log R``. They
|
|
42
|
+
add because precision and recall are estimated on independent samples. The
|
|
43
|
+
expression is a first-order approximation; it agrees with the bootstrap to
|
|
44
|
+
a correlation above 0.999 over the range of rates seen in practice.
|
|
45
|
+
"""
|
|
46
|
+
for name, v in (("precision", precision), ("recall", recall)):
|
|
47
|
+
if not 0 < v < 1:
|
|
48
|
+
raise ValueError(f"{name} must be strictly between 0 and 1")
|
|
49
|
+
z = norm.ppf(1 - (1 - level) / 2)
|
|
50
|
+
var = (1 - precision) / (precision * n_precision) + (1 - recall) / (recall * n_recall)
|
|
51
|
+
return float(z * math.sqrt(var))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def required_sample_size(
|
|
55
|
+
target_half_width: float,
|
|
56
|
+
expected_precision: float,
|
|
57
|
+
expected_recall: float,
|
|
58
|
+
level: float = 0.99,
|
|
59
|
+
allocation: str = "equal_variance",
|
|
60
|
+
) -> Budget:
|
|
61
|
+
"""Annotations needed per unit to reach a target half-width.
|
|
62
|
+
|
|
63
|
+
``allocation='equal_variance'`` splits the variance budget evenly between
|
|
64
|
+
the two terms, which is what the asymmetric floors in the paper approximate:
|
|
65
|
+
recall is the noisier of the two, so it gets the larger share of the budget
|
|
66
|
+
for a given target. ``allocation='equal_n'`` solves for a single sample size
|
|
67
|
+
used on both sides, which is simpler to explain but wastes annotations on
|
|
68
|
+
whichever rate is already well estimated.
|
|
69
|
+
|
|
70
|
+
The result is a floor, not a recommendation: it assumes the guessed rates
|
|
71
|
+
are right and ignores stratification.
|
|
72
|
+
"""
|
|
73
|
+
if not 0 < target_half_width < 1:
|
|
74
|
+
raise ValueError("target_half_width must be a fraction strictly between 0 and 1")
|
|
75
|
+
z = norm.ppf(1 - (1 - level) / 2)
|
|
76
|
+
budget = (target_half_width / z) ** 2 # total allowed variance
|
|
77
|
+
|
|
78
|
+
vp = (1 - expected_precision) / expected_precision
|
|
79
|
+
vr = (1 - expected_recall) / expected_recall
|
|
80
|
+
|
|
81
|
+
if allocation == "equal_variance":
|
|
82
|
+
n_p = math.ceil(2 * vp / budget)
|
|
83
|
+
n_r = math.ceil(2 * vr / budget)
|
|
84
|
+
elif allocation == "equal_n":
|
|
85
|
+
n = math.ceil((vp + vr) / budget)
|
|
86
|
+
n_p = n_r = n
|
|
87
|
+
else:
|
|
88
|
+
raise ValueError("allocation must be 'equal_variance' or 'equal_n'")
|
|
89
|
+
|
|
90
|
+
achieved = half_width_closed_form(
|
|
91
|
+
expected_precision, expected_recall, n_p, n_r, level=level
|
|
92
|
+
)
|
|
93
|
+
return Budget(n_precision=n_p, n_recall=n_r, achieved_half_width=achieved)
|