Study operations · WPP BAV

Fold a refield wave into a published study.

Convert the SPSS delivery, drop the respondents that failed quality control from both waves, append, re-weight the combined sample and fill in the derived variables — in about twenty seconds, with a report you can hand to whoever signs the data off.

convert_savfilter_respondentsmerge_datasetsweight_respondentsderive_variables
The output

A drop-in replacement for the export it was built from

Same six files, same columns, same canonical sort order, same CRLF and quoting BAV ships — just with the bad respondents gone and the refield respondents added. Built for the China 2026 Concepts refield; nothing in it is study-specific beyond a handful of defaults you can override on the command line.

18,774
respondents in the merged sample, from 11,506 original and 7,268 refield
7.6M
brand-fact rows written, from 12.8M rows read out of the .sav delivery
~20s
end-to-end run, all five stages, ~2.5 GB peak memory
100%
agreement when the derivation rule is tested against the published wave
How it works

Five stages, each one standalone

run_pipeline.py chains them for the common case. Run them individually when you need to inspect or adjust an intermediate — every stage writes its own directory and its own timestamped log.

01

Convert — convert_sav.py

Reads every .sav in a directory and writes the three respondent-level BAV CSVs. Files are classified by their variable signature rather than their filename, so a renamed or re-cut delivery converts without configuration.

  • Drops the rows for questions a respondent was never served, and the unselected side of multi-punch brand measures — BAV exports store only answered rows.
  • Bins 0–100 slider captures back onto the original wave's 1–6 agreement scale so the two waves are comparable, and refuses to recode anything already on the target scale.
02

Filter — filter_respondents.py

Reads the quality-control workbook and filters each export down to its good respondents. _wts.csv is the authoritative roster: the respondents that survive there become the set the other files are filtered against.

  • Every sheet exposing a respondent key is read — as a flag list or a plain keep-list — and the keys are unioned.
  • Aborts if no mapping key appears in the export, which means the mapping and the data are for different studies.
03

Merge — merge_datasets.py

Appends one or more exports onto a base and re-sorts into canonical BAV order, so the result is indistinguishable in structure from a single-wave export. Respondent keys must be disjoint — a collision means two different people would share an identity, so the merge aborts.

  • --study-key restamps all six files, because a merged export is a new study and the source key is already taken.
  • Metadata is restamped at the byte level rather than round-tripped through a CSV writer, preserving each file's own quoting style exactly.
04

Weight — weight_respondents.py

Rim-weights the combined sample to population targets — gender × age interlocked, city tier, income — within each version cell, normalised to mean 1.0. The refield delivery carries no weights at all, so this is what makes the merged export usable for weighted analysis.

  • Supports interlocked dimensions, a study universe (18–74 rather than 18+), and explicit handling of respondents no target describes.
  • Writes a standalone Markdown report plus CSVs: provenance, method, every target, achieved marginals against target, effective sample size and anomalies.
05

Derive — derive_variables.py

Computes the variables a refield delivery leaves out even when the source variable came through fine — Income Group is the standard case. Percentiles are weighted by default, because that is the distribution the study claims to represent.

  • Existing values are kept and only the gaps filled, so a published wave is never quietly recomputed.
  • Where both a derived and a published value exist they are compared, and the run fails below 99% agreement — turning the original wave into a test of the rule.
Weighting

Targets that were recovered, not guessed

The China 2026 targets were reverse-engineered from the original wave's own weights. Raking each version cell to them reproduces all 19,591 published weights to three decimal places — correlation 0.99999999, maximum relative error 0.016%.

Interlocked, not just marginal

"50% women" and "18% aged 18–24" are both satisfied by a sample where every young respondent is a woman. Crossing gender and age into one dimension closes that hole; the cost is thinner cells, which is a judgement call the anomaly report gives you the evidence for.

A universe, stated separately

Response rates thin out at the top of the age range, so the study is defined over 18–74. Respondents outside it take no part in the raking — so a handful of 80-year-olds cannot pull everyone else's weights around — but keep their rows and a neutral weight.

Reband, don't cap

When weights run away the tool names the thin band and suggests merging it into a neighbour. Capping hides the symptom and quietly stops you hitting the targets you asked for. It is still available if you want it, which is why achieved marginals are always reported.

These targets are inferred, not authoritative
They are the targets the original fieldwork was weighted to, so using them keeps the two waves consistent. If the client has published population figures, put those in the config instead.
Guardrails

Designed to fail loudly rather than ship plausible nonsense

Weighting is usually where fieldwork faults first surface. A band that is empty in some version cells but not others rarely says anything about the population — it usually means those cells were never fielded as designed.

CheckWhat it meansDefault
emptyA target band has no respondents in some weighting group. Its share is silently absorbed by the other bands, corrupting every weight in the group.Aborts the run
sparseFewer than 30 respondents in a cell. Thin cells are what produce runaway weights.Warning
extremeA respondent weighs more than 5× the average.Warning
inefficientA weighting group falls below 50% efficiency.Warning
Key collisionThe same respondent key appears in both waves, so two different people would share an identity.Aborts the merge
Orphan respondentSomeone answers questions but has no row in the roster file.Aborts the filter
Derivation driftThe derived variable disagrees with the published values it was tested against.Fails below 99%
Empty cells stop the run by design
The report names the exact group, dimension and band, with the count it expected — so a fielding fault localises immediately instead of surfacing weeks later as weights nobody can explain.
China 2026 Concepts

What the run produced

Published under study key 1258. Weighted at 82.7% efficiency, effective sample size 15,506 against the 18,756 respondents in the universe, with weights ranging 0.39–4.84 and every version cell averaging exactly 1.0.

FileOriginal kept / totalRefield kept / totalMerged
_wts.csv · respondents11,506 / 19,5917,268 / 8,23818,774
_df.csv · demographic answers844,362 / 1,410,535457,953 / 515,6441,302,315
_bf.csv · brand facts4,612,740 / 6,437,2052,957,297 / 3,214,0447,570,037
The merged respondent set equals the workbook's ALL GOOD RESPONDENTS sheet exactly.
Every surviving original row is unchanged apart from the study key.
All refield brand, category and measure keys resolve against the existing metadata.
Each metadata file is byte-identical to its source once the study key is substituted back.
No duplicate key rows; canonical sort order, CRLF and quoting match the source.
Income Group derived for 7,232 respondents reproduces all 11,486 pre-existing values.
Six sparse anomalies were accepted deliberately
The 55+ cells hold 26–29 respondents against about 60 expected, in 6 of 28 version cells — a genuine shortfall of older respondents rather than a fielding fault. Collapsing 45–54 and 55+ would clear the warnings and lift efficiency to 85.3%, but 55+ would then land at 14.95% against its 18% target.
Getting started

Nothing to install

The scripts carry PEP 723 inline dependencies, so uv resolves them on first run. Python 3.11+ for TOML configs; pandas, pyreadstat, openpyxl and numpy otherwise. Every script has --help, --dry-run, and prompts for anything missing when the terminal is interactive.

# the whole thing, end to end
uv run ./run_pipeline.py \
  --existing-dir   "existing data" \
  --refield-dir    "refield data" \
  --mapping        "respondent mappings/refield and original good resps.xlsx" \
  --output-dir     "output" \
  --prefix         "China 2026 Concepts" \
  --new-study-key  1258 \
  --weight-config  config/china-2026-targets.toml \
  --derive-income-group

# or audit the weights already in a delivery, without recomputing anything
uv run ./weight_respondents.py --input-dir "output/final" --audit-only
Every run logs its full effective configuration
A log file on its own is enough to reproduce the run. Exit codes are 0 success, 1 unexpected error, 2 aborted on a failed guardrail, 130 interrupted — so it drops into CI as it is.