Skip to content

Commit bd71d59

Browse files
committed
fix: include OLS slope-intercept covariance in BET uncertainty propagation
sigma(S_BET) and sigma(C) treated the OLS slope and intercept as independent. For a fit with an intercept they are not: Cov(m,b) = -xbar*Var(m). Propagation now uses Var(m+b) = Var(m) + Var(b) + 2*Cov(m,b) for Vm and S_BET, and the corresponding covariance term for C. Validated against a 200,000-sample Monte Carlo: the new sigma(S_BET) matches the true sampling spread to within 0.00%%; the previous independent-errors formula overestimated it by 22.9%%. The docstring claim of a ~5%% effect was wrong and has been removed. Fitted values are unchanged: best.S_BET on the BETSI reference isotherms is identical (HKUST-1 1554.7766, Zeolite-13X 841.9653).
1 parent 55f1a18 commit bd71d59

2 files changed

Lines changed: 72 additions & 13 deletions

File tree

rouquerol.py

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -85,13 +85,15 @@ def fit_bet_window(p_rel: np.ndarray, n: np.ndarray) -> dict:
8585
scipy.stats.linregress returns the standard errors of the slope
8686
(stderr) and intercept (intercept_stderr) directly. First-order
8787
error propagation through Vm = 1/(slope + intercept) and
88-
C = 1 + slope/intercept then gives
88+
C = 1 + slope/intercept then gives, with d = m + b,
8989
90-
σ(S_BET) = S_BET · √(σ_slope² + σ_intercept²) / (slope + intercept)
91-
σ(C) = √(σ_slope²/intercept² + σ_intercept²·slope²/intercept⁴)
90+
Var(d) = Var(m) + Var(b) + 2·Cov(m, b)
91+
σ(S_BET) = S_BET · √Var(d) / d
92+
σ(C) = √(σ_slope²/intercept² + σ_intercept²·slope²/intercept⁴
93+
− 2·slope·Cov(m, b)/intercept³)
9294
93-
The slope–intercept covariance is neglected; against a Monte-Carlo
94-
check this changes σ(S_BET) by only ~5 %.
95+
The slope–intercept covariance is included: for OLS with an
96+
intercept, Cov(m, b) = −x̄ · Var(m), with x̄ = mean(p_rel).
9597
"""
9698
p_rel = np.asarray(p_rel, dtype=float)
9799
n = np.asarray(n, dtype=float)
@@ -102,18 +104,23 @@ def fit_bet_window(p_rel: np.ndarray, n: np.ndarray) -> dict:
102104
slope, intercept, r = reg.slope, reg.intercept, reg.rvalue
103105
sigma_slope = float(reg.stderr)
104106
sigma_intercept = float(reg.intercept_stderr)
107+
cov_slope_intercept = float(-np.mean(p_rel) * sigma_slope ** 2)
108+
var_denom = sigma_slope ** 2 + sigma_intercept ** 2 + 2.0 * cov_slope_intercept
109+
var_denom = max(var_denom, 0.0) # clamp guards floating-point noise only
105110
denom = slope + intercept
106111
Vm = np.nan if abs(denom) < 1e-30 else 1.0 / denom
107112
C = np.nan if abs(intercept) < 1e-30 else 1.0 + slope / intercept
108113
S_BET = Vm * N2_BET_FACTOR if np.isfinite(Vm) else np.nan
109114
if np.isfinite(Vm):
110-
sigma_Vm = abs(Vm) * np.hypot(sigma_slope, sigma_intercept) / abs(denom)
111-
sigma_S_BET = abs(S_BET) * np.hypot(sigma_slope, sigma_intercept) / abs(denom)
115+
sigma_Vm = abs(Vm) * np.sqrt(var_denom) / abs(denom)
116+
sigma_S_BET = abs(S_BET) * np.sqrt(var_denom) / abs(denom)
112117
else:
113118
sigma_Vm = sigma_S_BET = np.nan
114119
if np.isfinite(C):
115-
sigma_C = np.hypot(sigma_slope / intercept,
116-
sigma_intercept * slope / intercept ** 2)
120+
var_C = ((sigma_slope / intercept) ** 2
121+
+ (sigma_intercept * slope / intercept ** 2) ** 2
122+
- 2.0 * slope * cov_slope_intercept / intercept ** 3)
123+
sigma_C = np.sqrt(max(var_C, 0.0)) # clamp guards floating-point noise only
117124
else:
118125
sigma_C = np.nan
119126
return {
@@ -129,6 +136,7 @@ def fit_bet_window(p_rel: np.ndarray, n: np.ndarray) -> dict:
129136
"y": y,
130137
"S_BET": float(S_BET) if np.isfinite(S_BET) else np.nan,
131138
"sigma_S_BET": float(sigma_S_BET),
139+
"cov_slope_intercept": cov_slope_intercept,
132140
}
133141

134142

@@ -179,6 +187,7 @@ class RouquerolWindow:
179187
sigma_Vm: float
180188
sigma_C: float
181189
sigma_S_BET: float
190+
cov_slope_intercept: float
182191
pm_exp: float
183192
pm_theory: float
184193
c1_C_positive: bool
@@ -206,6 +215,7 @@ def as_dict(self) -> dict:
206215
"sigma_Vm": self.sigma_Vm,
207216
"sigma_C": self.sigma_C,
208217
"sigma_S_BET": self.sigma_S_BET,
218+
"cov_slope_intercept": self.cov_slope_intercept,
209219
"pm_exp": self.pm_exp,
210220
"pm_theory": self.pm_theory,
211221
"c1_C_positive": self.c1_C_positive,
@@ -269,6 +279,7 @@ def evaluate_window(
269279
sigma_Vm=fit["sigma_Vm"],
270280
sigma_C=fit["sigma_C"],
271281
sigma_S_BET=fit["sigma_S_BET"],
282+
cov_slope_intercept=fit["cov_slope_intercept"],
272283
pm_exp=pm_exp,
273284
pm_theory=pm_th,
274285
c1_C_positive=c1,

tests/test_rouquerol.py

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""Unit tests for Rouquerol BET range selection."""
22

3+
import os
4+
35
import numpy as np
6+
import pandas as pd
47
import pytest
58

69
from rouquerol import (
@@ -94,8 +97,9 @@ def test_noisy_type_iv_isotherm_recovers_surface_area():
9497
def test_bet_uncertainty_propagation():
9598
"""σ(S_BET) and σ(C) come from the linregress standard errors via
9699
first-order propagation: ~0 for a perfect fit, positive for noisy
97-
data, reproducing σ_S = S_BET·√(σ_slope² + σ_intercept²)/(slope+intercept),
98-
and bracketing the true surface area."""
100+
data, reproducing σ_S = S_BET·√Var(d)/d with
101+
Var(d) = σ_slope² + σ_intercept² + 2·Cov(slope, intercept),
102+
Cov(slope, intercept) = −x̄·σ_slope², and bracketing the true surface area."""
99103
p, n, C, Vm = _ideal_bet_isotherm()
100104
fit = fit_bet_window(p, n)
101105
assert fit["sigma_S_BET"] == pytest.approx(0.0, abs=1e-9)
@@ -105,8 +109,9 @@ def test_bet_uncertainty_propagation():
105109
n_noisy = n * (1.0 + rng.normal(0.0, 0.005, size=len(p)))
106110
fit = fit_bet_window(p, n_noisy)
107111
assert fit["sigma_slope"] > 0 and fit["sigma_intercept"] > 0
108-
expected = (abs(fit["S_BET"])
109-
* np.hypot(fit["sigma_slope"], fit["sigma_intercept"])
112+
var_denom = (fit["sigma_slope"] ** 2 + fit["sigma_intercept"] ** 2
113+
+ 2.0 * fit["cov_slope_intercept"])
114+
expected = (abs(fit["S_BET"]) * np.sqrt(var_denom)
110115
/ abs(fit["slope"] + fit["intercept"]))
111116
assert fit["sigma_S_BET"] == pytest.approx(expected, rel=1e-12)
112117
assert fit["sigma_C"] > 0
@@ -129,3 +134,46 @@ def test_bet_sensitivity_heatmap_dimensions_and_stability():
129134
for i in range(N):
130135
for j in range(i):
131136
assert np.isnan(result["s_bet"][i, j])
137+
138+
139+
def test_betsi_s_bet_unchanged_with_covariance():
140+
"""Regression guard: adding the slope–intercept covariance must not move
141+
the fitted values. best.S_BET on the bundled BETSI isotherms must match
142+
the pre-covariance references to 3 decimal places."""
143+
examples = os.path.join(
144+
os.path.dirname(os.path.abspath(__file__)), os.pardir, "examples")
145+
references = {
146+
"betsi_HKUST-1.csv": 1554.7766,
147+
"betsi_Zeolite-13X.csv": 841.9653,
148+
}
149+
for filename, expected in references.items():
150+
df = pd.read_csv(os.path.join(examples, filename), encoding="utf-8-sig")
151+
p = df.iloc[:, 0].to_numpy(dtype=float)
152+
n = df.iloc[:, 1].to_numpy(dtype=float)
153+
best = select_bet_range(p, n)["best"]
154+
assert best is not None, f"{filename}: no Rouquerol window was found"
155+
assert best.S_BET == pytest.approx(expected, abs=1e-3), (
156+
f"{filename}: best.S_BET = {best.S_BET:.4f}, expected {expected:.4f}"
157+
)
158+
159+
160+
def test_cov_slope_intercept_is_negative():
161+
"""Cov(slope, intercept) = −x̄·Var(slope): strictly negative on a noisy
162+
window with all p_rel in (0,1), since x̄ > 0 and Var(slope) > 0."""
163+
p, n, _, _ = _ideal_bet_isotherm()
164+
assert np.all((p > 0) & (p < 1))
165+
rng = np.random.default_rng(7)
166+
n_noisy = n * (1.0 + rng.normal(0.0, 0.005, size=len(p)))
167+
fit = fit_bet_window(p, n_noisy)
168+
assert fit["sigma_slope"] > 0
169+
assert fit["cov_slope_intercept"] < 0
170+
171+
172+
def test_cov_slope_intercept_matches_ols_identity():
173+
"""OLS with an intercept: Cov(slope, intercept) == −mean(p)·σ_slope²."""
174+
p, n, _, _ = _ideal_bet_isotherm()
175+
rng = np.random.default_rng(7)
176+
n_noisy = n * (1.0 + rng.normal(0.0, 0.005, size=len(p)))
177+
fit = fit_bet_window(p, n_noisy)
178+
assert fit["cov_slope_intercept"] == pytest.approx(
179+
-np.mean(p) * fit["sigma_slope"] ** 2)

0 commit comments

Comments
 (0)