Area-level EBLUP with analytical and bootstrap MSE
Tutorials
Area-level
Fay-Herriot
Python
Fit the Fay-Herriot area-level model in Python with svy-sae: EBLUP estimates, shrinkage, out-of-sample areas, and Prasad-Rao or bootstrap MSE, checked against known true values.
Author
Mamadou S. Diallo, Ph.D.
Modified
November 5, 2026
Keywords
Fay-Herriot model Python, area-level small area estimation, EBLUP area level Python, Prasad-Rao MSE, parametric bootstrap MSE, out-of-sample small areas
WarningAbout these examples
This tutorial illustrates the svy-sae API; it is not a rigorous small area analysis. The models are kept simple so each step stays readable, and in many places, from the choice of covariates to the variance model, better choices could be made to improve them.
The Fay–Herriot model (Fay and Herriot 1979) combines two sources of information about each area. The direct estimate \(\hat\theta_i\) is unbiased but noisy, with known sampling variance \(D_i\); a regression on covariates \(x_i\), observed for every area, is stable but ignores what is specific to the area:
An area with a precise direct estimate keeps most of it; an area with a noisy one leans on the regression. Areas outside the sample get the regression prediction alone, the synthetic estimate \(x_i'\hat\beta\).
This tutorial demonstrates how to fit the model with AreaLevel.fh(), read its results, predict districts the survey did not reach, and measure uncertainty with the analytical (Prasad–Rao) and bootstrap MSE. Because the data are synthetic, each step is checked against the true district poverty rates.
Setting up the inputs
We continue with the district poverty rates and census covariates from Preparing inputs:
direct = direct.with_columns(pl.col("n").log().alias("log_n"))smoothed = sae.smooth_variance(direct, x="log_n", variance="var_direct", df="df")
A baseline without covariates
AreaLevel takes the frame of direct estimates, one row per area. Start with a model that has no covariates, x=[]: every district then borrows from the national average.
model = sae.AreaLevel(smoothed)fit_base = model.fh( y="est", x=[], area="geo2", variance="variance_smoothed", pop_data=district_x,)print(fit_base)
╭─────────────── SAE: Fay-Herriot (MSE: Prasad-Rao) - Area Level ───────────────╮│Method REML Log Likelihood 24.1529 ││No. Areas 59 AIC -44.3057 ││No. Obs 59 BIC -40.1506 ││KIC -42.3057 ││││││Term Estimate Std.Err z p-value [0.025 0.975]││ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ││ Intercept 0.2309 0.0205 11.25 0.0000 0.1906 0.2711 ││││││Variance components: area variance: 0.0124191 (se 0.00464) ││││ Area keys (geo2): data 59 rows, pop_data 61 rows, in both 59, out-of-sample 2 ││││ Findings: ││ - Residuals not normal ││ - Area effects not normal ││ - 2 influential areas ││ - 41 of 61 areas with CV above 0.3 ││ - 6 sampled areas without a precision gain │╰───────────────────────────────────────────────────────────────────────────────╯
pop_data is the census frame from Preparing inputs. It lists every district to predict, and supplies the covariates that the frame of direct estimates does not have. The summary reports how the districts matched: 59 in both frames, 2 in the census only.
The findings at the bottom are the model checks covered in Checking the model.
How much the model borrows
The per-district diagnostics include the shrinkage factor \(\gamma_i\), the weight on the direct estimate. Compare the districts with the smallest and largest samples:
Districts of 25 households keep less than a quarter of their direct estimate; the largest keep more than half. Each estimate pred lies between direct and synthetic, at the point set by gamma.
Adding a covariate
The share of households with piped water, from the census, tracks poverty closely. It is the covariate chosen in Choosing covariates:
fit = model.fh( y="est", x=["piped_water"], area="geo2", variance="variance_smoothed", pop_data=district_x,)print(fit)
╭─────────────── SAE: Fay-Herriot (MSE: Prasad-Rao) - Area Level ───────────────╮│Method REML Log Likelihood 41.7020 ││No. Areas 59 AIC -77.4040 ││No. Obs 59 BIC -71.1714 ││KIC -74.4040 ││││││Term Estimate Std.Err z p-value [0.025 0.975]││ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ ││ Intercept 0.6029 0.0527 11.43 0.0000 0.4995 0.7062 ││ piped_water -0.6602 0.0894 -7.38 0.0000 -0.8354 -0.4850 ││││││Variance components: area variance: 5.86419e-07 (se 4.607e-05) ││││ Area keys (geo2): data 59 rows, pop_data 61 rows, in both 59, out-of-sample 2 ││ Covariates from data: -; from pop_data: piped_water ││││ Findings: ││ - Area variance at the boundary ││ - 2 influential areas ││ - 12 of 61 areas with CV above 0.3 │╰───────────────────────────────────────────────────────────────────────────────╯
piped_water is not in smoothed, so it is read from pop_data for every district, as the summary reports.
With \(\hat\sigma^2_u\) near zero, every \(\gamma_i\) is near zero and every estimate is essentially synthetic. The fit says so in its findings (SAE_SIGMA_U_BOUNDARY). It means the regression on piped water leaves no between-district variation that the model can tell apart from sampling error.
Here the truth can confirm it. Regress the true district rates on piped water and measure the variance left over:
import numpy as npsampled = district_x.join(truth, on="geo2").join(direct.select("geo2"), on="geo2")X = np.column_stack([np.ones(sampled.height), sampled["piped_water"].to_numpy()])resid = sampled["true_rate"].to_numpy() - X @ np.linalg.lstsq(X, sampled["true_rate"].to_numpy())[0]print(f"True area variance around the regression: {resid.var(ddof=2):.5f}")print(f"Median smoothed sampling variance: {smoothed['variance_smoothed'].median():.5f}")
True area variance around the regression: 0.00049
Median smoothed sampling variance: 0.01384
The true rates sit close to the line: what remains is small next to the sampling variances, too small for 59 districts to estimate. The synthetic estimate is the right answer for these data.
NoteMethod note
A boundary estimate is not always this benign. With weak covariates it can also mean the sampling variances are too large, which is one reason to smooth them and check them against the direct variances. The MSE also ignores the uncertainty of a boundary estimate.
Reading the results
Fixed effects, with standard errors and confidence intervals:
fit.to_polars("fixed")
shape: (2, 7)
term
est
se
t_score
p_value
lci
uci
str
f64
f64
f64
f64
f64
f64
"Intercept"
0.60287
0.05273
11.433154
0.0
0.499519
0.70622
"piped_water"
-0.660175
0.089397
-7.38478
1.5277e-13
-0.835393
-0.484957
Variance components, one row each (the Fay–Herriot model has one, area):
fit.to_polars("components")
shape: (1, 4)
name
kind
est
se
str
str
f64
f64
"area"
"variance"
5.8642e-7
0.000046
Model statistics. The information criteria are the ones used in Choosing covariates:
s = fit.statsprint(f"log-likelihood {s.log_likelihood:.2f}, AIC {s.aic:.2f}, BIC {s.bic:.2f}, KIC {s.kic:.2f}")
They get the synthetic estimate \(x_i'\hat\beta\), with an MSE of \(\hat\sigma^2_u\) plus the uncertainty of \(\hat\beta\). fit.area_match records how the keys of the two frames matched and where each covariate came from:
print(fit.area_match)
Area keys (geo2): data 59 rows, pop_data 61 rows, in both 59, out-of-sample 2
Covariates from data: -; from pop_data: piped_water
Measuring uncertainty
mse selects how the MSE is computed, and method how \(\sigma^2_u\) is estimated:
Table 1: MSE and estimation options for fh()
Argument
Value
Description
mse
"analytical"(default)
Prasad–Rao second-order approximation (Prasad and Rao 1990), with a bias term under ML
"bootstrap"
Parametric bootstrap with n_reps replicates (default 1,000); rstate seeds it
method
"reml"(default)
Restricted maximum likelihood
"ml"
Maximum likelihood
Refit with the bootstrap MSE. The estimates are unchanged; only the MSE differs:
Here the two disagree: the bootstrap MSE is less than half the analytical one. The parametric bootstrap draws new data from the fitted model, and with \(\hat\sigma^2_u\) at zero those data have no area effects, so its MSE only reflects the uncertainty of \(\hat\beta\). The Prasad–Rao MSE also accounts for the uncertainty of \(\hat\sigma^2_u\), and its average matches the actual squared error against the truth.
WarningThe bootstrap at the boundary
When the fit reports SAE_SIGMA_U_BOUNDARY, prefer the analytical MSE where it exists. The bootstrap is still required where no analytical MSE exists: for most transformations, and for benchmarked estimates.
How close did we get?
Compare the direct estimates and both models with the true rates: error (RMSE), districts with a CV above 30%, and how many 95% intervals contain the truth.
The baseline already cuts the error by a third by borrowing from the national average. Piped water does far better, for all 61 districts including the two the survey did not reach, and most of its intervals contain the true rate.
Summary
This tutorial fitted the Fay–Herriot model with AreaLevel.fh():
A baseline with x=[], shrinking each district toward the national average, more for smaller samples
A covariate read from the census through pop_data, which also predicts the districts outside the sample
An area variance at the boundary, flagged by the fit and explained by the truth
Results: fixed effects, variance components, statistics, and predictions with MSE, CV, and intervals
Uncertainty from the Prasad–Rao approximation or the parametric bootstrap, which understates the MSE when \(\hat\sigma^2_u\) is at the boundary
Next steps
Piped water was given here. The next tutorial chooses it among the census covariates.
Fay, Robert E., and Roger A. Herriot. 1979. “Estimates of Income for Small Places: An Application of James-Stein Procedures to Census Data.”Journal of the American Statistical Association 74 (366): 269–77.
Prasad, N. G. N., and J. N. K. Rao. 1990. “The Estimation of the Mean Squared Error of Small-Area Estimators.”Journal of the American Statistical Association 85 (409): 163–71.