Tutorials › Regression Analysis (Undergraduate) › Writing Reproducible Data Preparation Scripts

Reproducible Analysis and Reporting · Tutorial 945 of 991

Writing Reproducible Data Preparation Scripts

Write data-preparation scripts that make each change visible and verify that the resulting variables and records match expectations.

Intermediate 12 min read

What You'll Learn

  • Import specified source files and check that expected columns are present.
  • Transform variables while retaining clear units and meanings.
  • Recode values using explicit, checked mappings.
  • Join tables using identified keys and verified key uniqueness.
  • Check expected variables, values, and records after each preparation step.
  • Recognize how duplicate join keys can unexpectedly multiply records.

Why Make Data Preparation Explicit?

A regression analysis depends on the data that reach the model. If an analyst changes a spreadsheet by hand, recodes values in an undocumented way, or joins two files without checking their keys, the resulting data may be difficult to reproduce and easy to misinterpret. A data-preparation script makes these operations visible: it states which files are read, what is changed, how records are combined, and what checks establish that the result is as intended.

The goal is not simply to make code run. A useful script also checks the shape and contents of the data at important steps. If an input file loses a column, a code value changes, or a join unexpectedly duplicates participants, a check should make the problem apparent before the prepared data are used in a regression analysis.

Definition: A reproducible data-preparation script is a recorded sequence of explicit operations that reads specified input data, creates or modifies variables, combines data when needed, and produces a checked output. Rerunning the sequence on the same inputs should produce the same prepared data, subject to the same software behavior and settings.

The examples use R-style commands and hypothetical data. Their purpose is to show how to express and verify preparation steps; they do not describe a real study. In each example, the checks are part of the script, not informal observations made after it runs.

Import Data and Check What Arrived

An import step should name the source file and make important expectations explicit. At a minimum, check that the file was read with the columns needed for the next operation. When the number of rows is known in advance, or when a known identifier set provides a stronger check, verify that as well. A successful import command alone does not establish that the correct file or variables were imported.

For example, an R-style script might read a comma-separated file with dat = read.csv("participants.csv"). It can then check required columns with stopifnot(all(c("id", "arm_code", "weight_lb") %in% names(dat))). If the identifier is expected to be unique, it can check stopifnot(!anyDuplicated(dat$id)). These checks turn assumptions about the incoming file into conditions that the script can test.

A check should be chosen for a specific reason. If a data provider regularly adds extra, irrelevant columns, testing for required columns is generally more useful than requiring an exact column list. If the analysis requires a particular identifier or measurement, however, failing loudly when it is absent is safer than continuing with an incomplete data set.

Worked Example: Importing and Transforming a Measurement

Suppose a hypothetical participant file contains four records, with columns id, arm_code, and weight_lb. The identifier is unique, the arm codes are 0 and 1, and weight is recorded in pounds. Prepare a version with treatment labels and weight in kilograms.

idarm_codeweight_lb
2010154
2021176
2030132
2041165

First import the file and check the required columns and identifiers: dat = read.csv("participants.csv"); stopifnot(all(c("id", "arm_code", "weight_lb") %in% names(dat))); stopifnot(!anyDuplicated(dat$id)). Before recoding, check that the observed arm codes are among the two values the script knows how to label: stopifnot(all(dat$arm_code %in% c(0, 1))). Without this check, a new code such as 2 could be silently assigned an unintended label.

Create the prepared variables explicitly: dat$arm = ifelse(dat$arm_code == 1, "Program", "Usual care"); dat$weight_kg = dat$weight_lb / 2.20462. The conversion divides pounds by 2.20462 pounds per kilogram. For participant 201, \(154 / 2.20462 \approx 69.85\) kg; for participant 202, \(176 / 2.20462 \approx 79.83\) kg. The four resulting weights are about 69.85, 79.83, 59.87, and 74.84 kg, respectively.

Finally, check what was created: stopifnot(all(c("id", "arm", "weight_kg") %in% names(dat))); stopifnot(nrow(dat) == 4); stopifnot(all(dat$arm %in% c("Program", "Usual care"))). The output still has four participant records, and its new weight variable is in kilograms. Keeping the original weight variable can also help make the conversion auditable; if it is removed, that should be an explicit decision rather than an accidental side effect.

Make Transformations and Recodings Unambiguous

A transformation calculates a variable from one or more existing variables. A recoding maps recorded values to new values, often changing numeric codes into meaningful labels. In either case, the script should state the rule and the output name clearly. Names such as weight_kg communicate more than a generic name such as weight2, and recording units in variable names or accompanying documentation helps prevent later confusion.

The input values also need checking. If a recode is written to handle only 0 and 1, check that all nonmissing input codes belong to that set. If missing values are allowed, decide how the language’s recoding function treats them and verify that the intended missingness is preserved. Do not turn an unexpected code into a familiar category simply to make the script complete.

Checks can cover both structure and values. For example, after calculating a change score, verify that the new column exists and that its values equal the specified subtraction for records with both measurements observed. A small set of hand-calculated values is useful during development. Once the code is established, automated checks can guard against future changes in the input.

Join Tables by Checked Keys

A join combines columns from separate tables using one or more matching variables, called keys. Before joining, identify what one row represents in each table and check whether the key is unique where it is supposed to be unique. Also check that key variables have compatible formats. For instance, the text value "031" is not necessarily equivalent to the number 31; converting identifiers casually can remove meaningful leading zeros.

A common operation is a left join: retain every row in a left-hand table and add matching columns from a right-hand table. If no right-hand row matches a left-hand key, the added columns are missing for that record. The result should be checked for the intended records and for missing joined values when a match was expected.

Worked Example: Joining a Site Lookup Table

Suppose a hypothetical participant table has four records, with unique participant IDs and a site ID for each participant. A separate lookup table gives one site name per site ID.

Participant idsite_id
301A
302B
303A
304C
site_idsite_name
ANorth
BCentral
CSouth

The participant table has four rows, and the lookup has exactly one row per site ID. In the script, check !anyDuplicated(participants$id) and !anyDuplicated(site_lookup$site_id) before joining. Then use a left join, such as joined = dplyr::left_join(participants, site_lookup, by = "site_id").

The expected output has the same four participant IDs, now with site names North, Central, North, and South. Check the number of rows with stopifnot(nrow(joined) == nrow(participants)), check that the joined site names are present with stopifnot(!anyNA(joined$site_name)), and check that the sorted IDs match: stopifnot(identical(sort(joined$id), sort(participants$id))). These checks test different things: the row count, the success of the lookup, and the identity of the retained records.

This join is many-to-one: several participants may share a site, but each site ID appears only once in the lookup. If the lookup instead contained two rows for site A, participants 301 and 303 could each appear twice in the joined result. The output might still contain the expected columns, but it would no longer represent one row per participant.

Why Key Checks Protect the Record Count

The record-count check for a left join follows from what happens to each left-hand row. If the right-hand key is unique, any left-hand key has either one matching right-hand row or no matching row. The left join therefore contributes exactly one output row for each left-hand row: a matched row in the first case, or a row with missing right-hand values in the second. Thus, the number of output rows equals the number of left-hand rows.

$$ \text{Right key unique} \quad\Longrightarrow\quad n_{\text{joined}} = n_{\text{left}} $$

If a right-hand key occurs \(k\) times, where \(k > 1\), a left-hand row with that key may instead produce \(k\) output rows. This explains why checking uniqueness before a join is not a cosmetic step: it tests a condition that protects the intended row structure. A matching row count is helpful, but it does not replace checking that the right-hand key is unique or that the resulting IDs are the expected ones.

Build a Preparation Sequence With Checkpoints

A clear preparation script separates major operations so that each step can be inspected and checked before the next one begins. The following sequence is useful for many regression data sets:

1
Import specified inputs.
Read each named source file and check the columns and basic identifier properties needed for the next steps.
2
Transform and recode explicitly.
Create named variables using stated rules, and check that input values fall within the codes the rules handle.
3
Join using identified keys.
Check key uniqueness where required, combine the tables, and verify row counts and matched values.
4
Check the prepared result.
Confirm that required columns are present and that record identities and expected values agree with the preparation plan.

For example, a final check might verify that the prepared table contains an ID, treatment label, outcome, and required adjustment variables; that the ID is unique if one row per participant is intended; and that a hand-calculated transformed value agrees with the script’s result. These checks do not establish that the scientific model is appropriate. They establish that the data passed to later analysis are the data the preparation steps were meant to create.

Worked Example: Combining Two Participant Files

Suppose a hypothetical study stores baseline measurements and follow-up measurements in separate files, each intended to contain one row per participant. The baseline file has IDs 401, 402, and 403 with baseline values 120, 128, and 124 mmHg. The follow-up file has the same IDs with follow-up values 118, 125, and 121 mmHg. The intended prepared variable is the change in systolic blood pressure, defined as follow-up minus baseline, in mmHg.

Import both files and check that each has the required ID and measurement columns. Check that each ID is unique within its file, and check that the sorted ID sets agree. For example, compare sort(baseline$id) with sort(followup$id); both should be 401, 402, 403. Then join by ID: wide = dplyr::left_join(baseline, followup, by = "id").

Check nrow(wide) == nrow(baseline), that the IDs in the result match the baseline IDs, and that no follow-up measurement is missing when a match is required. Create the change with wide$change_mmHg = wide$followup_mmHg - wide$baseline_mmHg. The three calculations are \(118 - 120 = -2\) mmHg, \(125 - 128 = -3\) mmHg, and \(121 - 124 = -3\) mmHg. Check that the output variable exists and that these values appear for IDs 401, 402, and 403, respectively.

This sequence makes the direction and units of the change explicit. Reversing the subtraction would produce the opposite sign, even though the magnitude would be unchanged. Naming the variable change_mmHg and stating “follow-up minus baseline” make the intended meaning easier to verify.

Common Mistake: Treating a Successful Join as a Correct Join

A frequent mistake is to assume that a join is correct because the software ran without an error. That reasoning is insufficient: the join can run while duplicate keys multiply records, a key mismatch leaves missing values, or an unintended key combines the wrong rows. The correction is to state what one row represents, identify the join key, check the relevant key uniqueness before joining, and compare the output records and values with expectations.

Another pitfall is to fix unexpected values manually in a spreadsheet after the script runs. That creates a result that the script cannot reproduce. Instead, investigate why the input or rule did not match expectations, make the intended change explicit in the script, and add a check that would reveal the same problem on a future run.

Key takeaway: A preparation script should reveal each import, transformation, recoding, and join, and should test the expected variables and records at the points where errors could arise. Explicit steps plus targeted checks make prepared data easier to rerun and audit.

Check Your Understanding

Answer each question by referring to the preparation rules and checks in this tutorial.

  1. A source file is expected to contain an ID and a weight in pounds. Name two import checks that could reveal a changed or unsuitable file.
  2. A recoding rule handles only treatment codes 0 and 1. Why should the script check the observed nonmissing codes before applying the rule?
  3. A left-hand table has 50 rows and a right-hand lookup has a unique key. Under what conditions should a left join retain 50 rows, and what should happen when a left-hand key has no match?
  4. What can happen to the row count if a key appears twice in the right-hand table? Give one check that would detect the cause before joining.
  5. For a change variable defined as follow-up minus baseline, what is the change when baseline is 130 mmHg and follow-up is 126 mmHg? Include the unit and sign.