import numpy as np
import svy
hld_data = svy.datasets.load(name="hld_sample_wb_2023", source="bundled")
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.1710 0.0000 0.0000 0.1710 0.1710 │ │ geo_02 0.2679 0.0000 0.0000 0.2679 0.2679 │ │ geo_03 0.2513 0.0000 0.0000 0.2513 0.2513 │ │ geo_04 0.3098 0.0000 0.0000 0.3098 0.3098 │ │ │ ╰──────────────────────────────────────────────────────────╯
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 9896.0000 0.0000 0.0000 9896.0000 9896.0000 │ │ geo_02 15502.0000 0.0000 0.0000 15502.0000 15502.0000 │ │ geo_03 14541.0000 0.0000 0.0000 14541.0000 14541.0000 │ │ geo_04 17928.0000 0.0000 0.0000 17928.0000 17928.0000 │ │ │ ╰────────────────────────────────────────────────────────────────────╯
# 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 17.1013 0.0000 0.0000 17.1013 17.1013 │ │ geo_02 26.7890 0.0000 0.0000 26.7890 26.7890 │ │ geo_03 25.1283 0.0000 0.0000 25.1283 25.1283 │ │ geo_04 30.9814 0.0000 0.0000 30.9814 30.9814 │ │ │ ╰────────────────────────────────────────────────────────────╯
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 171.0128 0.0000 0.0000 171.0128 171.0128 │ │ geo_02 267.8902 0.0000 0.0000 267.8902 267.8902 │ │ geo_03 251.2831 0.0000 0.0000 251.2831 251.2831 │ │ geo_04 309.8139 0.0000 0.0000 309.8139 309.8139 │ │ │ ╰──────────────────────────────────────────────────────────────╯
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.0945 0.0186 0.1968 0.0626 0.1403 │ │ Rural Yes 0.2432 0.0186 0.0765 0.2070 0.2835 │ │ Urban No 0.0236 0.0145 0.6164 0.0066 0.0813 │ │ Urban Yes 0.6386 0.0145 0.0228 0.6082 0.6680 │ │ │ ╰───────────────────────────────────────────────────────────────╯
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.094527 ┆ 0.24323 │
│ Urban ┆ 0.023598 ┆ 0.638645 │
└────────┴──────────┴──────────┘
# 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.095 ± 0.019 ┆ 0.243 ± 0.019 │
│ Urban ┆ 0.024 ± 0.015 ┆ 0.639 ± 0.015 │
└────────┴───────────────┴───────────────┘
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 ┆ 9.452745 ┆ 24.322982 │
│ Urban ┆ 2.359756 ┆ 63.864517 │
└────────┴──────────┴───────────┘
# 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 ┆ 5470.02 ┆ 14074.98 │
│ Urban ┆ 1365.52 ┆ 36956.48 │
└────────┴─────────┴──────────┘
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.0945 [0.0626, 0.1403] ┆ 0.2432 [0.2070, 0.2835] │
│ Urban ┆ 0.0236 [0.0066, 0.0813] ┆ 0.6386 [0.6082, 0.6680] │
└────────┴─────────────────────────┴─────────────────────────┘
# 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.094527 ┆ 0.018606 ┆ 0.062597 ┆ 0.140308 ┆ Two-Way ┆ 0.05 │
│ Rural ┆ Yes ┆ 0.24323 ┆ 0.018606 ┆ 0.207045 ┆ 0.283478 ┆ Two-Way ┆ 0.05 │
│ Urban ┆ No ┆ 0.023598 ┆ 0.014546 ┆ 0.006559 ┆ 0.081282 ┆ Two-Way ┆ 0.05 │
│ Urban ┆ Yes ┆ 0.638645 ┆ 0.014546 ┆ 0.608242 ┆ 0.667977 ┆ 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.2064 0.0389 0.1886 0.1262 0.2866 │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ -0.0436 -1.1189 25.0000 0.2738 │ │ │ ╰─────────────────────────────────────────────────╯
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.1189
Degrees of freedom: 25.0
p-value: 0.2738
# 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.0436
95% CI: [-0.1238, 0.0366]
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.043567 | 0.038938 | -0.123761 | 0.036627 | -1.118891 | 25.0 | 0.273823 |
# Raw estimates only
pov_status_mean_h0.to_polars("estimates")| est | se | cv | lci | uci |
|---|---|---|---|---|
| f64 | f64 | f64 | f64 | f64 |
| 0.206433 | 0.038938 | 0.188623 | 0.126239 | 0.286627 |
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 8059.2760 962.8024 0.1195 6076.3473 10042.2048 │ │ urbrur Urban 14573.2217 1011.0039 0.0694 12491.0201 16655.4233 │ │ │ │ │ │ Test statistic │ │ │ │ diff t df p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 6513.9457 4.6658 25.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: 6513.95
95% CI: [3638.61, 9389.28]
# Individual group estimates
for est in exp_by_urbrur.estimates:
print(f"{est.group_level}: {est.est:.2f} (SE: {est.se:.2f})")Rural: 8059.28 (SE: 962.80)
Urban: 14573.22 (SE: 1011.00)
# 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 | 6513.945685 | 1396.107996 | 3638.607443 | 9389.283927 | 4.665789 | 25.0 | 0.000089 |
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.000044
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.0980, p=0.3163
Region geo_02: diff=-0.0771, p=0.2264
Region geo_03: diff=0.0124, p=0.8993
Region geo_04: diff=-0.0300, p=0.6347
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 Lumley and Scott (2013), 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.2991 5.0586 25.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: 5.0586
Degrees of freedom: 25.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.2991
95% CI: [0.1773, 0.4209]
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.299125 | 0.059132 | 0.177341 | 0.420909 | 5.058626 | 25.0 | 0.000032 |
# 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 (4 levels) │ │ │ │ │ │ Estimate Std Err CV Lower Upper │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ │ │ │ │ Test statistic │ │ │ │ df Chisq F p_value │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 3 27.1816 9.0605 0.0004 │ │ │ ╰───────────────────────────────────────────╯
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: 9.0605
Numerator df: 3
Denominator df: 23
p-value: 0.0004
# 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" | 3 | 23.0 | 27.181565 | 9.060522 | 0.000381 |
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 │ │ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ │ 1.0241 4.7891 25.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" | 1.024072 | 0.213832 | 0.583677 | 1.464467 | 4.789145 | 25.0 | 0.000064 |
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.4313 5.1862 25.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.431315 | 0.083166 | 0.260032 | 0.602599 | 5.186194 | 25.0 | 0.000023 |
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.2566 3.8704 25.0000 0.0007 │ │ │ ╰───────────────────────────────────────────╯
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.256637 | 0.066307 | 0.120075 | 0.393199 | 3.870438 | 25.0 | 0.000691 |
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.000016
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 →