import numpy as np
import svy
hld_data = svy.datasets.load(name="hld_sample_wb_2023")
hld_design = svy.Design(stratum=("geo1", "urbrur"), psu="ea", wgt="hhweight")
hld_sample = svy.Sample(data=hld_data, design=hld_design)
# Create household poverty line and binary poverty status
hld_sample = hld_sample.wrangling.mutate(
{
"hhpovline": svy.col("hhsize") * 1800,
"pov_status": svy.when(svy.col("tot_exp") < svy.col("hhpovline")).then(1).otherwise(0),
}
)Categorical Data Analysis for Complex Surveys in Python
Design-aware tabulations, cross-tabulations, hypothesis tests, and rank tests
categorical data analysis survey Python, survey cross-tabulation Python, weighted contingency table Python, design-adjusted t-test survey Python, weighted frequency table Python, survey tabulation Python, two-sample t-test survey Python, chi-square test complex survey Python, domain estimation survey Python, complex survey hypothesis testing Python, survey rank test Python, Wilcoxon rank test complex survey Python, Kruskal-Wallis test survey Python, design-based nonparametric test Python
Categorical data analysis spans descriptive techniques—contingency tables and cross-tabulations with design-adjusted tests—and model-based approaches for categorical outcomes (logistic, multinomial, loglinear, and mixed-effects GLMs; see Agresti (2013)).
In complex survey applications, ignoring stratification, clustering, or unequal weights can misstate uncertainty. This tutorial shows how to use svy to produce design-aware tabulations, cross-tabulations, t-tests for group differences, and nonparametric rank tests, with standard errors that respect the sample design.
Setting Up the Sample
We’ll use the imaginary country household dataset from World Bank (2023):
One-Way Tabulation
The tabulate() method produces weighted frequency tables that account for the survey design.
Tabulate the first-level administrative unit (geo1):
hld_admin1_tab = hld_sample.categorical.tabulate(rowvar="geo1")
print(hld_admin1_tab)╭────────────────────── Table: geo1 ───────────────────────╮ │ │ │ Row Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ geo_01 0.1370 0.0041 0.0296 0.1292 0.1452 │ │ geo_02 0.0956 0.0038 0.0402 0.0883 0.1034 │ │ geo_03 0.1126 0.0033 0.0296 0.1062 0.1193 │ │ geo_04 0.1481 0.0047 0.0319 0.1391 0.1577 │ │ geo_05 0.0843 0.0031 0.0373 0.0783 0.0907 │ │ geo_06 0.0747 0.0032 0.0425 0.0687 0.0812 │ │ geo_07 0.0727 0.0040 0.0557 0.0651 0.0810 │ │ geo_08 0.0411 0.0039 0.0938 0.0342 0.0494 │ │ geo_09 0.1148 0.0040 0.0351 0.1071 0.1229 │ │ geo_10 0.1191 0.0047 0.0398 0.1101 0.1287 │ │ │ ╰──────────────────────────────────────────────────────────╯
Changing Output Units
By default, tabulate() produces proportions. Use the units parameter to get counts or percentages:
# Counts
hld_admin1_tab_count = hld_sample.categorical.tabulate(
rowvar="geo1",
units="count",
)
print("Table with counts")
print(hld_admin1_tab_count)Table with counts
╭────────────────────────────── Table: geo1 ───────────────────────────────╮ │ │ │ Row Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ geo_01 342733.0000 10680.8501 0.0312 321798.9184 363667.0816 │ │ geo_02 239113.0000 10113.6242 0.0423 219290.6609 258935.3391 │ │ geo_03 281600.0000 8476.1378 0.0301 264987.0752 298212.9248 │ │ geo_04 370596.0000 12852.9351 0.0347 345404.7101 395787.2899 │ │ geo_05 210960.0000 8080.7698 0.0383 195121.9821 226798.0179 │ │ geo_06 186992.0000 8198.4041 0.0438 170923.4232 203060.5768 │ │ geo_07 181766.0000 10639.1062 0.0585 160913.7350 202618.2650 │ │ geo_08 102927.0000 9977.5362 0.0969 83371.3884 122482.6116 │ │ geo_09 287141.0000 10651.1400 0.0371 266265.1492 308016.8508 │ │ geo_10 297927.0000 12817.4855 0.0430 272805.1901 323048.8099 │ │ │ ╰──────────────────────────────────────────────────────────────────────────╯
# Percentages
hld_admin1_tab_percent = hld_sample.categorical.tabulate(
rowvar="geo1",
units="percent",
)
print("Table with percentages:")
print(hld_admin1_tab_percent)Table with percentages:
╭─────────────────────── Table: geo1 ────────────────────────╮ │ │ │ Row Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ geo_01 13.6997 0.4269 0.0312 12.8629 14.5365 │ │ geo_02 9.5578 0.4043 0.0423 8.7655 10.3501 │ │ geo_03 11.2561 0.3388 0.0301 10.5920 11.9201 │ │ geo_04 14.8134 0.5138 0.0347 13.8065 15.8204 │ │ geo_05 8.4325 0.3230 0.0383 7.7994 9.0656 │ │ geo_06 7.4744 0.3277 0.0438 6.8321 8.1167 │ │ geo_07 7.2655 0.4253 0.0585 6.4320 8.0990 │ │ geo_08 4.1142 0.3988 0.0969 3.3325 4.8959 │ │ geo_09 11.4776 0.4257 0.0371 10.6431 12.3120 │ │ geo_10 11.9087 0.5123 0.0430 10.9046 12.9129 │ │ │ ╰────────────────────────────────────────────────────────────╯
Scaling Counts to a Custom Total
Use count_total to express counts on an arbitrary total (useful for scaled headcounts while preserving shares):
# Scale counts so the total sums to 1,000
hld_admin1_tab_n = hld_sample.categorical.tabulate(
rowvar="geo1",
count_total=1_000,
)
print(hld_admin1_tab_n)╭──────────────────────── Table: geo1 ─────────────────────────╮ │ │ │ Row Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ geo_01 136.9970 4.2693 0.0312 128.6293 145.3648 │ │ geo_02 95.5781 4.0426 0.0423 87.6547 103.5015 │ │ geo_03 112.5610 3.3881 0.0301 105.9205 119.2015 │ │ geo_04 148.1344 5.1376 0.0347 138.0650 158.2039 │ │ geo_05 84.3248 3.2300 0.0383 77.9940 90.6556 │ │ geo_06 74.7443 3.2771 0.0438 68.3214 81.1673 │ │ geo_07 72.6554 4.2527 0.0585 64.3203 80.9905 │ │ geo_08 41.1419 3.9882 0.0969 33.3252 48.9587 │ │ geo_09 114.7758 4.2575 0.0371 106.4313 123.1203 │ │ geo_10 119.0872 5.1234 0.0430 109.0455 129.1289 │ │ │ ╰──────────────────────────────────────────────────────────────╯
Two-Way Tabulation (Cross-Tabulation)
Cross-tabulations examine the relationship between two categorical variables. Use the colvar parameter to create a two-way table:
# Cross-tabulate urban/rural status by electricity access
urbrur_elec_tab = hld_sample.categorical.tabulate(
rowvar="urbrur",
colvar="electricity",
)
print(urbrur_elec_tab)╭───────────────── Table: urbrur × electricity ─────────────────╮ │ │ │ Row Col Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ Rural No 0.1520 0.0113 0.0746 0.1310 0.1757 │ │ Rural Yes 0.2970 0.0120 0.0403 0.2739 0.3211 │ │ Urban No 0.0186 0.0037 0.2012 0.0125 0.0275 │ │ Urban Yes 0.5325 0.0074 0.0140 0.5178 0.5471 │ │ │ ╰───────────────────────────────────────────────────────────────╯
Viewing as a Crosstab Matrix
The crosstab() method returns a Polars DataFrame in a familiar matrix format:
# Get crosstab matrix (estimates only)
urbrur_elec_crosstab = urbrur_elec_tab.crosstab()
print(urbrur_elec_crosstab)shape: (2, 3)
┌────────┬──────────┬──────────┐
│ urbrur ┆ No ┆ Yes │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 │
╞════════╪══════════╪══════════╡
│ Rural ┆ 0.151987 ┆ 0.296956 │
│ Urban ┆ 0.018563 ┆ 0.532494 │
└────────┴──────────┴──────────┘
# Include standard errors with the estimates
urbrur_elec_crosstab_se = urbrur_elec_tab.crosstab(stats=("est", "se"))
print(urbrur_elec_crosstab_se)shape: (2, 3)
┌────────┬───────────────┬───────────────┐
│ urbrur ┆ No ┆ Yes │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞════════╪═══════════════╪═══════════════╡
│ Rural ┆ 0.152 ± 0.011 ┆ 0.297 ± 0.012 │
│ Urban ┆ 0.019 ± 0.004 ┆ 0.532 ± 0.007 │
└────────┴───────────────┴───────────────┘
Two-Way Tables with Different Units
Just like one-way tables, you can change the output units:
# Cross-tabulation with percentages
urbrur_elec_pct = hld_sample.categorical.tabulate(
rowvar="urbrur",
colvar="electricity",
units="percent",
)
print("Cross-tabulation with percentages:")
print(urbrur_elec_pct.crosstab())Cross-tabulation with percentages:
shape: (2, 3)
┌────────┬───────────┬───────────┐
│ urbrur ┆ No ┆ Yes │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 │
╞════════╪═══════════╪═══════════╡
│ Rural ┆ 15.198691 ┆ 29.695594 │
│ Urban ┆ 1.856347 ┆ 53.249369 │
└────────┴───────────┴───────────┘
# Cross-tabulation with counts
urbrur_elec_count = hld_sample.categorical.tabulate(
rowvar="urbrur",
colvar="electricity",
units="count",
)
print("Cross-tabulation with counts:")
print(urbrur_elec_count.crosstab())Cross-tabulation with counts:
shape: (2, 3)
┌────────┬───────────────┬───────────────┐
│ urbrur ┆ No ┆ Yes │
│ --- ┆ --- ┆ --- │
│ str ┆ f64 ┆ f64 │
╞════════╪═══════════════╪═══════════════╡
│ Rural ┆ 380234.000686 ┆ 742910.999314 │
│ Urban ┆ 46441.251273 ┆ 1.3322e6 │
└────────┴───────────────┴───────────────┘
Crosstab Display Options
The crosstab() method offers several formatting options:
# Show estimates with confidence intervals
print(urbrur_elec_tab.crosstab(stats=("est", "lci", "uci"), precision=4))shape: (2, 3)
┌────────┬─────────────────────────┬─────────────────────────┐
│ urbrur ┆ No ┆ Yes │
│ --- ┆ --- ┆ --- │
│ str ┆ str ┆ str │
╞════════╪═════════════════════════╪═════════════════════════╡
│ Rural ┆ 0.1520 [0.1310, 0.1757] ┆ 0.2970 [0.2739, 0.3211] │
│ Urban ┆ 0.0186 [0.0125, 0.0275] ┆ 0.5325 [0.5178, 0.5471] │
└────────┴─────────────────────────┴─────────────────────────┘
# Export full table details to Polars DataFrame
df = urbrur_elec_tab.to_polars()
print(df)shape: (4, 8)
┌────────┬─────────────┬──────────┬──────────┬──────────┬──────────┬────────────┬───────┐
│ urbrur ┆ electricity ┆ est ┆ se ┆ lci ┆ uci ┆ table_type ┆ alpha │
│ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- ┆ --- │
│ str ┆ str ┆ f64 ┆ f64 ┆ f64 ┆ f64 ┆ str ┆ f64 │
╞════════╪═════════════╪══════════╪══════════╪══════════╪══════════╪════════════╪═══════╡
│ Rural ┆ No ┆ 0.151987 ┆ 0.011336 ┆ 0.130996 ┆ 0.175662 ┆ Two-Way ┆ 0.05 │
│ Rural ┆ Yes ┆ 0.296956 ┆ 0.011976 ┆ 0.27394 ┆ 0.321051 ┆ Two-Way ┆ 0.05 │
│ Urban ┆ No ┆ 0.018563 ┆ 0.003736 ┆ 0.012477 ┆ 0.027537 ┆ Two-Way ┆ 0.05 │
│ Urban ┆ Yes ┆ 0.532494 ┆ 0.007446 ┆ 0.517817 ┆ 0.547115 ┆ Two-Way ┆ 0.05 │
└────────┴─────────────┴──────────┴──────────┴──────────┴──────────┴────────────┴───────┘
T-Tests for Survey Data
The ttest() method performs design-adjusted t-tests that properly account for stratification, clustering, and weighting.
One-Sample T-Test
Test whether a population mean equals a hypothesized value:
# Test: Is the poverty rate different from 25%?
pov_status_mean_h0 = hld_sample.categorical.ttest(
y="pov_status",
mean_h0=0.25,
)
print(pov_status_mean_h0)╭────────────── T-Test: One-sample ───────────────╮ │ Y = 'pov_status' │ │ H₀: μ = 0.2500 │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 0.2288 0.0140 0.0613 0.2012 0.2564 │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ -0.0212 -1.5141 300.0000 0.1310 │ │ │ ╰─────────────────────────────────────────────────╯
Accessing Results Programmatically
# Test statistics
print(f"t-statistic: {pov_status_mean_h0.stats.t:.4f}")
print(f"Degrees of freedom: {pov_status_mean_h0.stats.df}")
print(f"p-value: {pov_status_mean_h0.stats.p_value:.4f}")t-statistic: -1.5141
Degrees of freedom: 300.0
p-value: 0.1310
# Difference from hypothesized mean
diff = pov_status_mean_h0.diff[0]
print(f"Difference: {diff.diff:.4f}")
print(f"95% CI: [{diff.lci:.4f}, {diff.uci:.4f}]")Difference: -0.0212
95% CI: [-0.0488, 0.0064]
Export to DataFrame
# Combined test results (default)
pov_status_mean_h0.to_polars()| y | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|
| str | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "pov_status" | -0.021237 | 0.014026 | -0.048839 | 0.006365 | -1.514116 | 300.0 | 0.131049 |
# Raw estimates only
pov_status_mean_h0.to_polars("estimates")| est | se | cv | lci | uci |
|---|---|---|---|---|
| f64 | f64 | f64 | f64 | f64 |
| 0.228763 | 0.014026 | 0.061313 | 0.201161 | 0.256365 |
Two-Sample T-Test
Compare means between two groups:
# Test: Does total expenditure differ between urban and rural areas?
exp_by_urbrur = hld_sample.categorical.ttest(
y="tot_exp",
group="urbrur",
)
print(exp_by_urbrur)╭─────────────────────── T-Test: Two-sample (unpaired) ───────────────────────╮ │ Y = 'tot_exp' │ │ Groups: urbrur = ['Rural' vs 'Urban'] │ │ │ │ │ │ Group Level Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ urbrur Rural 9116.6293 305.5200 0.0335 8515.3957 9717.8630 │ │ urbrur Urban 14437.9184 326.4021 0.0226 13795.5907 15080.2461 │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 5321.2891 11.9023 300.0000 <0.0001 │ │ │ ╰─────────────────────────────────────────────────────────────────────────────╯
Two-Sample Results
# Group difference
diff = exp_by_urbrur.diff[0]
print(f"Difference: {diff.diff:.2f}")
print(f"95% CI: [{diff.lci:.2f}, {diff.uci:.2f}]")Difference: 5321.29
95% CI: [4441.48, 6201.10]
# Individual group estimates
for est in exp_by_urbrur.estimates:
print(f"{est.group_level}: {est.est:.2f} (SE: {est.se:.2f})")Rural: 9116.63 (SE: 305.52)
Urban: 14437.92 (SE: 326.40)
# Export to DataFrame
exp_by_urbrur.to_polars()| y | group_var | paired | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|---|---|
| str | str | bool | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "urbrur" | false | 5321.289092 | 447.080293 | 4441.478438 | 6201.099746 | 11.902312 | 300.0 | 5.1425e-27 |
Alternative Hypotheses
Specify one-sided tests with the alternative parameter:
# Test: Is urban expenditure GREATER than rural expenditure?
exp_by_urbrur_greater = hld_sample.categorical.ttest(
y="tot_exp",
group="urbrur",
alternative="greater",
)
print(f"One-sided p-value: {exp_by_urbrur_greater.stats.p_value:.6f}")
print(f"Alternative: {exp_by_urbrur_greater.alternative}")One-sided p-value: 0.000000
Alternative: greater
Options:
alternative |
Null Hypothesis | Alternative Hypothesis |
|---|---|---|
"two-sided" |
μ₁ = μ₂ | μ₁ ≠ μ₂ |
"less" |
μ₁ ≥ μ₂ | μ₁ < μ₂ |
"greater" |
μ₁ ≤ μ₂ | μ₁ > μ₂ |
For one-sample tests, the comparison is against mean_h0.
Domain Estimation with by
Perform separate t-tests for each level of a domain variable:
# Test poverty rate vs 25% separately by region
pov_by_region = hld_sample.categorical.ttest(
y="pov_status",
mean_h0=0.25,
by="geo1",
)
# Returns a list of TTestOneGroup objects
for r in pov_by_region:
diff = r.diff[0]
print(f"Region {diff.by_level}: diff={diff.diff:.4f}, p={r.stats.p_value:.4f}")Region geo_01: diff=-0.1641, p=0.0000
Region geo_02: diff=0.0916, p=0.1097
Region geo_03: diff=-0.0314, p=0.4071
Region geo_04: diff=-0.0785, p=0.0275
Region geo_05: diff=0.0732, p=0.2347
Region geo_06: diff=-0.0313, p=0.5456
Region geo_07: diff=-0.0223, p=0.6639
Region geo_08: diff=0.0028, p=0.9730
Region geo_09: diff=0.0701, p=0.1176
Region geo_10: diff=-0.0229, p=0.5351
Rank Tests for Survey Data
When comparing distributions across groups, the classic Wilcoxon rank-sum and Kruskal–Wallis tests assume independent observations with equal selection probabilities—assumptions that break down under complex sampling. The ranktest() method implements the design-based rank tests of (lumley2013?), which replace raw ranks with estimated population mid-ranks computed from the survey weights and then apply a rank-score transformation. Standard errors come from the same Taylor-linearization machinery used elsewhere in svy, so stratification, clustering, and unequal weights are handled automatically.
Wilcoxon vs. Kruskal–Wallis
Under the hood, both tests use the same score function ("kruskal-wallis"). The distinction is purely about the number of groups: with exactly two groups svy runs a Wilcoxon-style t-test on the difference in mean rank scores; with three or more groups it runs a Kruskal–Wallis-style Wald / F-test on the full set of contrasts. You do not need to choose—ranktest() detects the number of groups and picks the right test automatically.
Two-Sample Rank Test (Wilcoxon)
Compare the total expenditure distribution between urban and rural households:
# Two-sample Wilcoxon rank test: expenditure by urban/rural
exp_rank_urbrur = hld_sample.categorical.ranktest(
"tot_exp",
group="urbrur",
method="kruskal-wallis",
drop_nulls=True,
)
print(exp_rank_urbrur)╭──── Rank Test: Two-sample (Wilcoxon) ─────╮ │ Y = 'tot_exp' │ │ Groups: urbrur = ['Rural' vs 'Urban'] │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 0.2357 11.4681 300.0000 <0.0001 │ │ │ ╰───────────────────────────────────────────╯
Even though we passed "kruskal-wallis", the result reports itself as “Wilcoxon” because there are only two groups:
print(f"Method displayed: {exp_rank_urbrur.method_name}")Method displayed: Wilcoxon
Accessing Two-Sample Results
# Test statistics
print(f"t-statistic: {exp_rank_urbrur.stats.value:.4f}")
print(f"Degrees of freedom: {exp_rank_urbrur.stats.df}")
print(f"p-value: {exp_rank_urbrur.stats.p_value:.4f}")t-statistic: 11.4681
Degrees of freedom: 300.0
p-value: 0.0000
# Difference in mean rank score
diff = exp_rank_urbrur.diff[0]
print(f"Difference: {diff.diff:.4f}")
print(f"95% CI: [{diff.lci:.4f}, {diff.uci:.4f}]")Difference: 0.2357
95% CI: [0.1952, 0.2761]
Export to DataFrame
# Combined test output (default)
exp_rank_urbrur.to_polars()| y | group_var | method | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|---|---|
| str | str | str | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "urbrur" | "Wilcoxon" | 0.235669 | 0.02055 | 0.195229 | 0.276109 | 11.468117 | 300.0 | 1.7227e-25 |
# Group-level estimates only
exp_rank_urbrur.to_polars("estimates")| y | group | group_level | est | se | cv | lci | uci |
|---|---|---|---|---|---|---|---|
| str | str | str | f64 | f64 | f64 | f64 | f64 |
K-Sample Rank Test (Kruskal–Wallis)
When the grouping variable has three or more levels, ranktest() automatically performs a Kruskal–Wallis F-test:
# K-sample rank test: total expenditure by region
exp_rank_region = hld_sample.categorical.ranktest(
"tot_exp",
group="geo1",
method="kruskal-wallis",
drop_nulls=True,
)
print(exp_rank_region)╭── Rank Test: K-sample (Kruskal-Wallis) ───╮ │ Y = 'tot_exp' │ │ Groups: geo1 (10 levels) │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ df Chisq F p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 9 24.6464 2.7385 0.0044 │ │ │ ╰───────────────────────────────────────────╯
print(f"Method displayed: {exp_rank_region.method_name}")
print(f"F-statistic: {exp_rank_region.stats.value:.4f}")
print(f"Numerator df: {int(exp_rank_region.stats.df_num)}")
print(f"Denominator df: {exp_rank_region.stats.df_den:.0f}")
print(f"p-value: {exp_rank_region.stats.p_value:.4f}")Method displayed: Kruskal-Wallis
F-statistic: 2.7385
Numerator df: 9
Denominator df: 292
p-value: 0.0044
# Export to DataFrame
exp_rank_region.to_polars()| y | group_var | method | ndf | ddf | chisq | f_stat | p_value |
|---|---|---|---|---|---|---|---|
| str | str | str | i64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "geo1" | "Kruskal-Wallis" | 9 | 292.0 | 24.64644 | 2.738493 | 0.004394 |
Alternative Score Functions
Besides the default Wilcoxon / Kruskal–Wallis scores, svy supports two additional built-in score transformations. Each applies a different function g(r) to the estimated population mid-ranks r, with N denoting the estimated population total:
| Method | Score | Sensitive to |
|---|---|---|
"kruskal-wallis" |
g(r) = r / N | General location shift |
"vander-waerden" |
g(r) = Φ⁻¹(r / N) | Location shift (emphasises tails) |
"median" |
g(r) = I(r > N / 2) | Difference in medians |
Van der Waerden Scores
Van der Waerden scores transform the proportional ranks through the standard-normal quantile function. This gives more weight to observations in the tails and tends to be more powerful than Wilcoxon scores when the underlying distribution is close to normal:
# Van der Waerden rank test
exp_vdw = hld_sample.categorical.ranktest(
"tot_exp",
group="urbrur",
method="vander-waerden",
drop_nulls=True,
)
print(exp_vdw)╭─ Rank Test: Two-sample (van der Waerden) ─╮ │ Y = 'tot_exp' │ │ Groups: urbrur = ['Rural' vs 'Urban'] │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 0.7858 10.9563 300.0000 <0.0001 │ │ │ ╰───────────────────────────────────────────╯
exp_vdw.to_polars()| y | group_var | method | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|---|---|
| str | str | str | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "urbrur" | "van der Waerden" | 0.785846 | 0.071725 | 0.644698 | 0.926995 | 10.956315 | 300.0 | 1.0138e-23 |
Mood’s Median Test
Mood’s median test reduces each observation to a binary indicator of whether its rank exceeds the population median. It is robust to outliers but less powerful than Wilcoxon or van der Waerden for detecting location shifts:
# Mood's median rank test
exp_median = hld_sample.categorical.ranktest(
"tot_exp",
group="urbrur",
method="median",
drop_nulls=True,
)
print(exp_median)╭───── Rank Test: Two-sample (Median) ──────╮ │ Y = 'tot_exp' │ │ Groups: urbrur = ['Rural' vs 'Urban'] │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 0.3653 11.9190 300.0000 <0.0001 │ │ │ ╰───────────────────────────────────────────╯
exp_median.to_polars()| y | group_var | method | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|---|---|
| str | str | str | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "urbrur" | "Median" | 0.365278 | 0.030647 | 0.304968 | 0.425587 | 11.919049 | 300.0 | 4.4873e-27 |
Custom Score Functions
You can supply your own rank-score transformation via the score_fn parameter. The function must accept (r, N) where r is the array of estimated population mid-ranks and N is the estimated population total, and return an array of scores. When using score_fn, omit the method parameter—providing both raises an error.
import numpy as np
# Custom: upper-quartile indicator
def upper_quartile(r, N):
return (r > 0.75 * N).astype(np.float64)
exp_custom = hld_sample.categorical.ranktest(
"tot_exp",
group="urbrur",
score_fn=upper_quartile,
drop_nulls=True,
)
print(exp_custom)╭─ Rank Test: Two-sample (upper_quartile) ──╮ │ Y = 'tot_exp' │ │ Groups: urbrur = ['Rural' vs 'Urban'] │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 0.2664 11.8310 300.0000 <0.0001 │ │ │ ╰───────────────────────────────────────────╯
The method name is inferred from the function’s __name__:
print(f"Method displayed: {exp_custom.method_name}")Method displayed: upper_quartile
exp_custom.to_polars()| y | group_var | method | diff | se | lci | uci | t | df | p_value |
|---|---|---|---|---|---|---|---|---|---|
| str | str | str | f64 | f64 | f64 | f64 | f64 | f64 | f64 |
| "tot_exp" | "urbrur" | "upper_quartile" | 0.26645 | 0.022521 | 0.22213 | 0.31077 | 11.831011 | 300.0 | 9.1831e-27 |
One-Sided Rank Tests
For two-sample tests, you can specify alternative="less" or alternative="greater":
# One-sided: is urban rank-score distribution shifted above rural?
exp_rank_greater = hld_sample.categorical.ranktest(
"tot_exp",
group="urbrur",
method="kruskal-wallis",
alternative="greater",
drop_nulls=True,
)
print(f"One-sided p-value: {exp_rank_greater.stats.p_value:.6f}")
print(f"Alternative: {exp_rank_greater.alternative}")One-sided p-value: 0.000000
Alternative: greater
Degrees of Freedom
For complex surveys, degrees of freedom depend on the design:
| Design | Degrees of Freedom |
|---|---|
| Weights only (SRS) | n - 1 |
| Stratified | n_strata - 1 |
| Clustered | n_psu - 1 |
| Stratified + Clustered | n_psu - n_strata - 1 |
Where:
n= number of observationsn_strata= number of stratan_psu= number of primary sampling units
Next Steps
Continue to Generalized Linear Models to learn how to fit linear and logistic regression models with design-adjusted standard errors.
Ready for regression modeling?
Learn GLMs in Generalized Linear Models →