cassionData Analysis

Lesson 2 of 8

Unit · The sample is not the population

Building the weights from the frame

Selection probability at each stage, the base weight it implies, the non-response adjustment, and the check that proves the whole thing — weights that sum to 56,428, which is exactly what the frame says exists.

PythonR120 minDemographic and Health Survey (DHS)Multiple Indicator Cluster Survey (MICS)SMART survey

A weight is one number with one meaning

A sampling weight is how many population units this sampled unit stands for.

That sentence is the whole of this lesson. Everything else is arithmetic to get there, and every check is a way of asking whether the weights still mean it.

The weight is the reciprocal of the probability of selection. A household with a 1-in-60 chance of being picked represents 60 households; a household with a 1-in-18 chance represents 18. Nothing more mysterious than that.

Stage one: probability proportional to size

Twenty-five enumeration areas were drawn from each stratum, with a probability proportional to the households each area holds.

P(area i selected) = n_h * M_i / M_h

where n_h is areas sampled in stratum h (25), M_i is households in area i, and M_h is households in the whole stratum.

import pandas as pd

frame = pd.read_csv("household-survey-frame-2025.v1.csv")

stratum_households = frame.groupby("stratum")["households"].sum()
selected = frame[frame["selected"] == True].copy()

selected["p_stage1"] = (
    25 * selected["households"] / selected["stratum"].map(stratum_households)
)
print(selected[["ea_id", "stratum", "households", "p_stage1"]].head())
library(dplyr)

stratum_households <- frame |> summarise(M_h = sum(households), .by = stratum)

selected <- frame |>
  filter(selected) |>
  left_join(stratum_households, by = "stratum") |>
  mutate(p_stage1 = 25 * households / M_h)

A larger area is more likely to be selected. That is the point of PPS: it puts the interviews where the people are, which stops a survey spending a third of its budget on hamlets.

Stage two: a fixed take

Fourteen households were drawn from each selected area, from that area’s listing.

P(household selected | area selected) = m / M_i

with m = 14.

selected["p_stage2"] = 14 / selected["households"]
selected["p_overall"] = selected["p_stage1"] * selected["p_stage2"]
print(selected.groupby("stratum")["p_overall"].describe()[["min", "max"]])
selected <- selected |>
  mutate(p_stage2 = 14 / households,
         p_overall = p_stage1 * p_stage2)

selected |> summarise(min = min(p_overall), max = max(p_overall), .by = stratum)

The two probabilities cancel

Multiply them out and M_i disappears:

P(household) = (n_h * M_i / M_h) * (m / M_i) = n_h * m / M_h

Every household in a stratum has the same overall probability, regardless of how big its area is. The design is self-weighting within a stratum.

Run the code and the minimum and maximum p_overall are identical within each stratum, which is the arithmetic confirming it. This is not a coincidence or a convenience — it is precisely why PPS is paired with a fixed take, in DHS, in MICS and in SMART. The size measure that decides which areas are visited is cancelled by the size measure that decides how many households are taken.

Two consequences worth holding. A weight that varies within a stratum in a PPS design means something is wrong — usually a frame whose size measure is out of date. And the base weight is a property of the stratum, not of the household, so it can be computed in three numbers.

base_weight = stratum_households / (25 * 14)
print(base_weight.round(1))
stratum_households |> mutate(base_weight = M_h / (25 * 14))
Stratum Frame households Base weight
Urban 21,270 60.8
Rural accessible 28,958 82.7
Rural remote 6,200 17.7

One rural remote household stands for eighteen; one rural accessible household stands for eighty-three. That ratio is the entire difference between 32.8% and 29.1%.

The non-response adjustment

Fourteen households were selected per area. Fewer were interviewed — 996 of the 1,050 selected. The households that were interviewed have to carry the ones that were not.

survey = pd.read_csv("household-survey-2025.v1.csv")

interviewed = selected.set_index("ea_id")["households_interviewed"]

survey["base_weight"] = survey["stratum"].map(base_weight)
survey["nr_adjustment"] = 14 / survey["ea_id"].map(interviewed)
survey["weight"] = survey["base_weight"] * survey["nr_adjustment"]

print(survey["weight"].describe()[["min", "max"]].round(1))
survey <- survey |>
  left_join(select(selected, ea_id, households_interviewed), by = "ea_id") |>
  left_join(mutate(stratum_households, base_weight = M_h / (25 * 14)), by = "stratum") |>
  mutate(weight = base_weight * 14 / households_interviewed)

range(survey$weight)

Weights now run from 17.7 to 96.5.

Adjust within the area, not within the stratum. The households that did not answer in a given area resemble the households that did answer in that area far more than they resemble the stratum average. Adjusting at stratum level is easier and throws away exactly the information that makes the adjustment worth making.

The assumption is stated and it is not free: non-respondents are assumed to resemble respondents in the same area. Where you have reason to think otherwise — locked compounds in one neighbourhood, a security incident on one day — say so in the limitations, because no weight can fix it.

The check that proves the whole construction

total = survey["weight"].sum()
frame_total = frame["households"].sum()
print(f"weights sum to {total:,.0f}; frame holds {frame_total:,}")
assert abs(total - frame_total) < 1
c(weights = sum(survey$weight), frame = sum(frame$households))

56,428 and 56,428. The weights sum to exactly the number of households the frame says exist, which is what “how many units this one stands for” has to mean if it means anything.

Run this check every time. It catches a stratum whose base weight used the wrong denominator, a non-response adjustment applied twice, and a join that dropped areas — three mistakes that are otherwise invisible because the resulting estimate still looks like a percentage.

Normalised weights, and when to use them

Some software wants weights averaging one rather than summing to the population.

survey["weight_norm"] = survey["weight"] / survey["weight"].mean()
survey <- survey |> mutate(weight_norm = weight / mean(weight))

Proportions and means are identical either way — the scaling cancels. Totals are not. A normalised weight cannot estimate “how many households are food insecure”, only “what share are”. Keep the population-scaled weight as the primary and derive the normalised one where a tool insists.

Person-level and sub-sample weights

Two more weights fall out of the same logic, and both are routinely got wrong.

A person-level weight is the household weight times the household size, because a household of eight represents eight times as many people as it does households.

people = survey[survey["household_size"].notna()].copy()
people["person_weight"] = people["weight"] * people["household_size"]
print(f"estimated population {people['person_weight'].sum():,.0f}")
people <- survey |>
  filter(!is.na(household_size)) |>
  mutate(person_weight = weight * household_size)

sum(people$person_weight)

That gives 333,336 against 328,127 in the frame listing — 1.6% apart. Twenty households have no recorded size and drop out, and a frame listing is itself an estimate made months earlier. A gap of that size is normal and worth stating; a gap of 20% means something is wrong.

A sub-sample weight applies when one unit is chosen from several. One child under five was measured per household, so a measured child in a household with three eligible children represents three children.

children = survey[survey["child_muac_mm"].notna()].copy()
children["child_weight"] = children["weight"] * children["children_under5"]
children <- survey |>
  filter(!is.na(child_muac_mm)) |>
  mutate(child_weight = weight * children_under5)

Skip that multiplication and children in large households are under-represented — and large households are systematically poorer, so the bias runs in a predictable direction. On this survey, MUAC-based acute malnutrition is 13.3% among the measured children and 11.4% once the child weights are applied.

Save the weights beside the data

survey[["household_id", "ea_id", "stratum", "base_weight",
        "nr_adjustment", "weight"]].to_csv("outputs/weights.csv", index=False)
survey |>
  select(household_id, ea_id, stratum, base_weight, households_interviewed, weight) |>
  readr::write_csv(here::here("outputs", "weights.csv"))

Keep the components, not just the final weight. When somebody asks in a year why one household counts for 96 and another for 18, the answer is in the columns rather than in your memory of a script.

What comes next

You can now produce a population estimate. The next unit asks what it cost: how many households a given precision needs, and why the textbook sample size formula gives an answer that is about half of what a cluster survey actually requires.

Teach this lesson

The lesson as a slide deck, with the prose kept in the speaker notes rather than on the slide. Generated from this page, so it cannot fall out of step with it.

Start the slideshowRead the slides

The PDF needs no software and projects from any machine. The PowerPoint file is there to be edited — add your organisation's branding, cut a section for a shorter session, or merge two lessons into a workshop.