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.
@@ -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)