Building Table 1 from a Real Survey

Lab · Module 5 · Mon Sep 14

Welcome to the Module 5 lab

Today, you’ll work with data from the Pew Research Center’s American Trends Panel (ATP) — Pew’s primary source of survey data for U.S. public opinion research. The ATP is a probability-based panel of roughly 10,000 adults, selected at random from across the entire United States and surveyed in both English and Spanish. The words probability-based and at random carry the weight here: you cannot volunteer for the ATP — only people Pew selects can join — yet nearly every adult living in the U.S. has a chance of being selected. Since 2018, that selection has used address-based sampling: invitations go to a random sample of households drawn from the U.S. Postal Service’s master list of residential addresses, and the adult with the next birthday in each chosen household is asked to take a survey and then invited onto the panel. That random-selection machinery is exactly what lets a few thousand respondents stand in for the views of all U.S. adults. As a result, the external validity of the ATP is strong, making it a reliable source for national-level research.

Because it is a panel, the same people take surveys repeatedly rather than a fresh sample being drawn each time; each administration is a wave. Not everyone invited responds, and Pew deliberately oversamples some groups, so every wave ships with a survey weight that corrects for both. The Going-further section shows what applying it changes. (Background: Pew’s overview of the ATP.)

We’ll use Wave 163, conducted in February 2025 with 5,097 U.S. adults. The survey focused on race and social issues and also asked respondents whether and how they use social media. Your task is to create a Table 1 describing the sample, stratified by social media use.

You’ll do two things:

  1. Procure the data yourself. The Pew Research Center data isn’t included in the PSY652_project data folder. So, you’ll navigate to their site, register, and download the .sav data file yourself. This is exactly the workflow you’ll do with your group for Project 1. Learning how to procure data from secondary sources is a key skill for any applied researcher.
  2. Build a report-ready Table 1 using the Pew data — recoding a real survey’s sentinel codes and labelled columns into analysis-ready variables, and verifying every step.

This is also your first lab using the haven and labelled packages — the standard tools for working with labelled survey data in R.

How the time works. You’ll do the hands-on work — Step 0 through Step 6 — in class, then we’ll pause for a short group debrief. The write-up (the reflection prose, the final render, and submitting to Canvas) is yours to finish at home. Plan for that split, and don’t rush the prose in the room — it’s the part that takes the most thought.

What you’ll leave with

In class: a notebook that runs end to end —

  • A real .sav file downloaded from Pew, filed in data/, with its codebook and questionnaire organized under documentation/
  • A loaded ATP Wave 163 dataset inspected through two lenses — a look_for() codebook and a skim() — before and after recoding, to verify your work
  • A report-ready Table 1 built with gtsummary::tbl_summary(), stratified by social-media-use status

At home: a short reflection paragraph on the workflow you followed, then the final render and Canvas submission.

WarningNo Run buttons in this lab — deliberate, not broken

Unlike every other lab, the code on this page cannot be edited or run in the browser. The blocks are display-only. You run everything in your own RStudio, inside m05_lab.qmd, against the file you download in Step 1.

The reason is the point of the lab: Pew grants access to you, individually, so the .sav lands in your own data/ folder and nowhere else. There is no shared copy for this page’s sandbox to load — which is exactly the situation your group will be in for Project 1.

The ✍️ / 💡 / 👀 tabs still structure each task — ✍️ shows the code with blanks, 💡 nudges, 👀 shows the completed answer — but you fill in the blanks in your m05_lab.qmd in RStudio, not on this page.


Step 0 · Get set up

Start in GitHub Desktop, before RStudio. Select PSY652_project, click Fetch origin, and click Pull origin if it appears. That is the pull half of the pull → edit → commit → push loop — the same loop you’ll close at the end of today’s lab.

Navigate to your PSY652_project folder and double-click PSY652_project.Rproj — the same way you have opened it since M02. RStudio launches with the project loaded.

The familiar four-pane layout should appear; in the Files pane you should see data/, documentation/, output/, and programs/. Confirm PSY652_project shows as the active project in RStudio’s project indicator (top-right).

Open the M05 lab skeleton

In the Files pane, click into programs/ and open m05_lab.qmd. The skeleton has section headings mirroring today’s lab steps — Setup → Introduction → Data → Select + inspect → Wrangling → Table 1 → Reflection — with TODO comments at each blank.

What you’re building

Everything between now and Step 6 exists to produce one table.

The substantive question the table is designed to answer is: How do social-media users and non-users differ from each other on key demographic variables?

The table’s structure is summarized below:

Rows Five demographics — age group, gender, education, race/ethnicity, and political party affiliation
Columns No and Yes (does the respondent use social media?), plus an Overall column
Statistics Count and percent for each category, with an “Unknown” row wherever someone refused
Labels Real category text — “18–29,” “A woman,” “College graduate+” — read straight from the .sav, never typed by you
Title A title and subtitle naming the survey and the wave, and flagging the numbers as unweighted

Two things have to happen before that table can exist, and they are the actual work of the lab: the data has to be procured (Step 1), and the survey’s 99 = Refused codes have to become genuine missing values while the labelled columns become factors (Step 5). Steps 2–4 are what make those transformations safe — reading the documentation, then inspecting every variable before you touch it, so you can prove afterward that the recode did what you intended.

Write your Introduction now. The # Introduction section at the top is yours to fill in — two or three sentences, now that you know where this is going. A good one names the data (Pew’s American Trends Panel, Wave 163, fielded February 2025), what the table describes (demographic characteristics of the people who responded), and how it is organized (stratified by whether the respondent uses social media). If you need an example, here is a model you can adapt:

This analysis describes the 5,097 U.S. adults who responded to Wave 163 of the Pew Research Center’s American Trends Panel in February 2025. It presents unweighted demographic characteristics of the respondents, stratified by whether they report using social media.

Make it yours rather than copying it — and note two words it chooses carefully. It says describes, not tests or predicts, which is the honest verb for a Table 1. And it says unweighted, because these percentages describe the 5,078 people who answered rather than the U.S. adult population. (The survey-weights page, linked at the end of the lab, shows what changes when you apply Pew’s weights.) You’ll write the closing Reflection in Step 7.


Step 1 · Procure the Pew data

This step is the lab’s first substantive lesson. You’re going to navigate to Pew Research Center, locate the dataset, download it, and place it in your project’s data/ folder.

Why we procure rather than ship

Most public-use survey datasets sit behind some procurement workflow — an email signup, a data-use agreement, a citation checkbox. Doing it once in lab gives you a baseline expectation for your group project.

How to procure ATP Wave 163

  1. Open a new browser tab and go to Pew’s American Trends Panel dataset library, then scroll down to Wave 163 (Feb. 2025) in the list.
  2. Click “Download dataset.” Depending on whether you’ve downloaded from Pew before, the site may ask you to register or sign in. Use your CSU email; the registration is free and one-time.
  3. Accept Pew’s terms of use. Pew asks you to agree to the conditions for using their data — read them and accept. You won’t be asked to explain what you’re using the data for. Those terms are the “data-use agreement” side of procurement: they’re short, they’re binding, and they’re worth actually reading once, because every restricted dataset you touch later in your career will hand you a longer version of the same thing.
  4. Download the .zip file (it should be named something like W163_Feb25.zip). It bundles the SPSS .sav data file plus its documentation — a codebook, the questionnaire, a methodology PDF, a readme, and a .csv copy of the data.
  5. Extract the .zip. It expands into a W163_Feb25/ folder. Open it and look around: the data file (ATP W163.sav — note the space) sits alongside the codebook and questionnaire that tell you what every variable means.
  6. File the data. Copy ATP W163.sav into PSY652_project/data/, then rename it to pew_atp_w163.sav — a clean, code-friendly name (no spaces) that matches what the skeleton expects.
  7. File the documentation. In your project’s documentation/ folder, make a subfolder named pew_atp_w163/ and move the rest of the extracted files into it — the codebook, questionnaire, methodology, and readme. (You can leave the .csv behind; it contains the same cases and isn’t needed for this lab.)
  8. Write a one-paragraph source note. This is a new kind of file for you, so here is the whole thing:
    • In RStudio: File → New File → Markdown File. That opens an empty, untitled document — no YAML, no code chunks, nothing to render.
    • Type your note (there’s a template in the box below to start from).
    • Save it as pew_atp_w163_source.md inside documentation/beside the pew_atp_w163/ folder you just made, not inside it. That placement is deliberate, and the box below says why. (RStudio adds the .md for you; if you’d started from Text File instead, you’d just type the .md yourself when saving — the extension is the only thing that makes it a Markdown file.)
    What’s a .md file? It’s a Markdown file — and you already write Markdown. Every .qmd you’ve made is Markdown prose plus code chunks plus a YAML header; a .md is just the prose part on its own. Same **bold**, same # headings, same - bullets you met in M02. Nothing renders it and nothing runs it — it’s a plain text file that happens to look tidy in GitHub, RStudio, and every text editor. The course codebooks sitting in your documentation/ folder are exactly this kind of file; open one if you want to see the shape.

Good habit · data in data/, documentation in documentation/

Six months from now, when you hit F_RACETHNMOD and can’t recall whether 4 means Asian or Other, the codebook is what rescues you. Reproducible projects keep that documentation with the project — not buried in your Downloads folder — your project’s documentation folder is a great place for it:

  • data/ holds the files you read into R (pew_atp_w163.sav).
  • documentation/ holds everything that explains the data — codebooks, questionnaires, methodology reports, data-use agreements. Grouping each source’s materials in its own subfolder (documentation/pew_atp_w163/) keeps things tidy once a project draws on more than one dataset.

Copy this into your new file and replace the bracketed parts. Two or three sentences is genuinely enough — this is a note to your future self, not a methods section.

# Pew ATP Wave 163 — source note

**Source.** Pew Research Center, American Trends Panel, Wave 163.
Downloaded from https://www.pewresearch.org/american-trends-panel-datasets/
on [today's date], after registering for a free account and accepting
Pew's terms of use.

**Field dates.** February 10-17, 2025. N = 5,097 U.S. adults.

**Files saved.** `data/pew_atp_w163.sav` (renamed from `ATP W163.sav`);
codebook, questionnaire, methodology, and readme in
`documentation/pew_atp_w163/`.

**Note.** The data and Pew's documentation are git-ignored and stay on
this machine. To rebuild the project elsewhere, re-download from the
link above.

What gets versioned. Your .gitignore line documentation/*/ keeps any subfolder of documentation/ local, so Pew’s codebook and questionnaire never reach GitHub — those are Pew’s files, distributed to you under the terms you accepted, and re-posting them is not yours to do.

Your flat pew_atp_w163_source.md sits outside that ignored subfolder, so it is tracked and travels with your code: the provider’s files stay local; your notes about them get versioned.

Checkpoint 1 · Data and documentation are filed

In RStudio’s Files pane:

  • PSY652_project/data/ contains pew_atp_w163.sav (alongside the .Rds files from earlier labs).
  • PSY652_project/documentation/pew_atp_w163/ contains the codebook, questionnaire, methodology, and readme.
  • PSY652_project/documentation/pew_atp_w163_source.md sits beside that folder with your short source note.

Step 2 · Get to know the data — read the codebook and questionnaire

Before you write a line of R, spend a few minutes with the two documents you just filed in documentation/pew_atp_w163/. Reading a dataset’s documentation before analyzing it is the single most useful habit for working with data you didn’t collect — and it’s exactly what you’ll do, unaided, for your group project.

  • The codebook (ATP W163 Codebook.xlsx) is the map of the dataset: one row per variable, with its name, its label, and every value code and what that code means. Open it and scroll. This is where you learn that F_RACETHNMOD has five categories and that code 4 means Other.
  • The questionnaire (ATP W163 Questionnaire.pdf) is the survey itself: the exact wording respondents saw, the order of the questions, and — crucially — the skip logic (which questions only some people were asked). Skip logic is why survey data has structural missingness: find the social-media block and notice that the importance items (SM11) are asked only of people who said they use social media. That routing is why those columns are blank for roughly one respondent in five — the structural missingness you’ll see for yourself in Step 4’s skim, and the reason those items sit out of this lab’s Table 1.

Try to answer these three questions straight from the documents — the same moves you’ll make on your own project data:

  1. In the codebook, find F_PARTY_FINAL. How many response categories does it have, and what code means “Refused”?
  2. In the questionnaire, find the exact wording of the social-media-use question (SNSUSE_W163). What determines whether a respondent is then asked the SM11 importance items?
  3. Anywhere in the codebook or readme, find the name of the survey weight variable.

Checkpoint 2 · You’ve oriented to the documentation

You opened the codebook and questionnaire and could locate a variable’s value labels and a question’s wording. The answers: F_PARTY_FINAL has 5 categories with 99 = Refused; a respondent is asked the SM11 items only if they use social media (the skip pattern that explains why those columns are blank for the roughly one respondent in five who does not use social media); and the weight is WEIGHT_W163.


Step 3 · Import the .sav with haven

Survey data usually arrives in one of a few statistical formats — most often SPSS (.sav), SAS (.sas7bdat / .xpt), or Stata (.dta) — often with a plain-text .csv copy alongside (as in this Pew download). Pew ships this wave as SPSS, so you’ll use haven’s read_sav(). haven — part of the tidyverse — reads all of these formats into R, preserving the variable labels that survey methodologists attach to every column.

Why the .sav and not the .csv next to it?

The download handed you both an SPSS .sav and a .csv of the very same data — so why reach for the .sav? Because the statistical formats carry the codebook inside the file:

  • SPSS (.sav), SAS (.sas7bdat / .xpt), and Stata (.dta) all embed variable labels, value labels (so a stored 1 knows it means “Yes”), and declared types. haven reads each into the same labelled tibble — read_sav(), read_sas() / read_xpt(), read_dta() — so today’s skill transfers to any of them.
  • CSV is the universal exchange format — it opens anywhere, in any software — but it’s “dumb”: it keeps the raw numbers and loses the labels and types, leaving you to recode everything by hand from the codebook.

The rule of thumb: when a labelled statistical file is offered, prefer it — the metadata comes free. When all you have is a CSV, keep the codebook close.

The # Data section’s import chunk is already written for you — it reads pew_atp_w163.sav and glimpses it:

pew_raw <- read_sav(here("data", "pew_atp_w163.sav"))

pew_raw |> glimpse()

Confirm that filename matches the file you renamed in Step 1, then run the chunk to load the data and peek at its structure.

In the glimpse() output you’ll see something a little different from prior labs — many columns appear with type <dbl+lbl> rather than <dbl> or <fct>.

What <dbl+lbl> means · labelled vectors

A labelled vector is a numeric vector with attached metadata: a label for the variable, and labels for each numeric value. So SNSUSE_W163 (the social-media-use question) stores 1, 2, 99 as raw numbers — but with attached labels that say 1 = "Yes, I use social media sites", 2 = "No, I do not use social media sites", 99 = "Don't know/Refused". This is exactly how SPSS stores data, and haven preserves it on import so the metadata isn’t lost.

Two consequences you’ll act on in Step 5:

  • The raw values are codes, not labels. Pipe a labelled column straight into tbl_summary() and it summarizes the underlying numbers (1, 2, 99) — and warns that the column is “an intermediate structure not meant for analysis.” To get a clean table, first convert to a factor with haven::as_factor(), which turns each value label into a factor level.
  • Watch the sentinel1 codes. Survey “Refused” answers are stored as a real number (here, 99), not as NA. Left alone, 99 becomes its own table row — so you send it to NA first (e.g., na_if(x, 99)) before summarizing.

Which columns hold the questions you need? You already found them in the codebook and questionnaire back in Step 2 — SNSUSE_W163 (“Do you ever use social media sites…?” — your stratifier) and the SM11_a_W163SM11_c_W163 battery (“How important is social media to you personally for…?” — the three items behind this lab’s optional challenge). The box below catalogs every variable this lab uses; in Step 4 you’ll pull them into R and print a proper codebook with look_for().

pew_raw · Pew ATP Wave 163 · 5,097 U.S. adults · Feb 2025

Pew prefixes its fielded demographic variables with F_ (for fielded). Each is a labelled numeric carrying a 99 = Refused code. The variables this lab uses:

  • F_AGECAT labelled — age category · 18–29, 30–49, 50–64, 65+
  • F_GENDER labelled — gender · A man, A woman, In some other way
  • F_EDUCCAT labelled — education · College graduate+, Some College, H.S. graduate or less
  • F_RACETHNMOD labelled — race/ethnicity · White non-Hispanic, Black non-Hispanic, Hispanic, Other, Asian non-Hispanic (that is Pew’s own code order — 4 = Other, 5 = Asian — so it is the order your Table 1 rows will follow)
  • F_PARTY_FINAL labelled — party · Republican, Democrat, Independent, Something else
  • SNSUSE_W163 labelled“Do you ever use social media sites…?” · 1 = Yes, 2 = No, 99 = DK/Refused — your stratifier
  • SM11_a_W163, SM11_b_W163, SM11_c_W163 labelled — importance of social media for (a) finding people who share your views, (b) getting involved with political/social issues, (c) expressing your opinions · 1 = Very important4 = Not at all important, 99 = DK/Refusedasked only of social-media users; combining them into a single score is this lab’s optional challenge
  • WEIGHT_W163 numeric — post-stratification survey weight; the one plain-numeric column among the labelled ones (you’ll watch it stand out in Step 4’s skim)

You’ll derive one variable in Step 5 — any_social_media, from SNSUSE_W163. (The three SM11_ items come along for the ride; combining them into a score is the optional challenge at the end of the lab.) The category labels in your finished table (18–29, A woman, …) come straight from the .sav; you convert them to factors in Step 5, but you never type the labels yourself.

Source: Pew Research Center American Trends Panel, Wave 163 (Feb 2025).

Checkpoint 3 · Labelled data loaded

Your render shows a glimpse() output that includes columns of type <dbl+lbl> — the labelled vectors (like SNSUSE_W163 and the SM11_* battery) that you’ll select and recode next.


Step 4 · Select and inspect your analysis variables

Before you change a single value, take a snapshot of what you’re starting with. Narrow the full import down to just the variables from the dataset box — an intermediate dataset — then inspect it. This “before” picture is what you’ll hold your recodes up against in Step 5.

Why bother? Because careful analysts never trust a recode blind. The most common way a data-cleaning bug survives is that nobody looked — the code ran without error, so it must have worked. Often it didn’t. You catch these by examining your variables before and after and confirming the change is exactly what you intended.

Inspect each variable through two lenses:

  • The codebooklook_for() (from the labelled package) scans a dataset’s variable names and labels and returns a tidy summary — a labelled-data companion to skim(). Run with no search term it documents the whole set you just selected: each variable’s label and its value-label decoder (1 = Yes99 = Refused). Piped through gt() it becomes a tidy in-R codebook.
  • The distributionskim(), which shows what’s actually in each column: its values, range, and missingness.

They’re complementary: look_for() tells you what a variable means; skim() tells you what’s in it. (That division of labor matters here — skim() reads the labelled columns as plain numbers, so it’s look_for() that surfaces the value labels.)

Add this to the # Select + inspect section of your m05_lab.qmd. The tabs below lay out the task — ✍️ shows the code with blanks, 💡 a hint, 👀 the completed version. Like everything on this page they’re display-only: complete the code in your m05_lab.qmd in RStudio, using the tabs to check yourself as you go.

pew_selected <- pew_raw |>
  ___( # keep only the columns from the dataset box
    F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL,
    SNSUSE_W163, starts_with("SM11_"), WEIGHT_W163
  )

# Lens 1 · the codebook — what each variable means
pew_selected |>
  look_for() |>
  convert_list_columns_to_character() |>
  gt()

# Lens 2 · the distribution — what's in each variable
pew_selected |> skim()

The verb that keeps only the columns you name — the one you’ve used since M04 — is select. starts_with(“SM11_”) is the tidyselect helper from the M05 module. For the codebook, look_for() with no search term returns every selected variable, and convert_list_columns_to_character() (from labelled) flattens its list-columns so gt() can render them.

pew_selected <- pew_raw |>
  select(
    F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL,
    SNSUSE_W163, starts_with("SM11_"), WEIGHT_W163
  )

pew_selected |>
  look_for() |>
  convert_list_columns_to_character() |>
  gt()

pew_selected |> skim()

One display gotcha as you read the skim: R prints very large or very small numbers in scientific notation — so a single wide-ranging column (like a survey weight) can tip the whole mean/p100 column into forms like 9.9e+01, and a sentinel code like 99 is easy to miss that way. The skeleton’s setup chunk sets options(scipen = 999) to keep numbers in plain form; if a skim ever shows you e+01, that one-line setting is the fix — a habit worth carrying into every project.

What the two lenses show you

The codebook (look_for()) lays out each variable’s meaning: its label (“Do you ever use social media sites?”) and its value-label decoder (1 = Yes, 2 = No, 99 = DK/Refused). This is the 99-means-Refused fact you’ll act on in Step 5 — now visible in R, not just buried in the PDF codebook.

The distribution (skim()) reads those same labelled columns as numbers, which makes two things jump out:

  • The 99 Refused code is hiding in plain sight. For the demographics and SNSUSE_W163, the p100 (maximum) is 99 — dragging the mean and sd up with it. Summarize as-is and that sentinel would poison every statistic.
  • The SM11_ items run 1–4 with a large n_missing — the structural missingness the questionnaire predicted (asked only of social-media users). WEIGHT_W163 is the one genuine numeric, averaging near 1.

Pin down two expectations before you recode: (1) the demographics should come back as factors with text labels and no 99; (2) the SM11_ items should be untouched, still carrying their structural missingness. Step 5 is where you check them.

Checkpoint 4 · You have a “before” snapshot

pew_selected holds just the ten analysis variables, and you’ve inspected them through both lenses: your look_for() codebook lists each variable’s label and value-label decoder (including 99 = Refused), and your skim() shows them in raw form — labelled numerics whose p100 is that 99 code, with the SM11_ items carrying the structural missingness you expected. You know exactly what your recode should change.


Step 5 · Wrangle and recode

Step 4 showed you what the raw variables look like. Now you turn them into the columns Table 1 actually needs.

What you’re making

Six columns, and it’s worth naming the destination before writing any code.

You have now You need
The stratifier SNSUSE_W163 — a labelled number: 1, 2, 99 any_social_media — a factor reading No / Yes, with refusals as missing
The five demographics labelled numbers: 1, 2, 3, … 99 factors showing real category text, with refusals as missing

Everything below serves that table. Both jobs share one requirement — ATP’s 99 = Refused has to become a genuine NA — and they differ in one way you’ll see in a moment.

(The three SM11_ importance items come along untouched. Combining them into a score is this lab’s optional challenge, not part of the main analysis.)

Job 1 · The stratifier

Start from what you actually have. SNSUSE_W163 stores three numbers, each carrying a label from Pew:

Stored value Pew’s label
1 “Yes, I use social media sites”
2 “No, I do not use social media sites”
99 “Don’t know/Refused/Web blank”

Those labels are accurate and unusable. This column becomes the column headers of your Table 1, and “Yes, I use social media sites” is not a column header. You want a short Yes and No, in that order, with 99 gone.

So you write the mapping yourself, with case_when() — the same verb you’ve used since M04:

case_when(
  SNSUSE_W163 == 1  ~ "Yes",
  SNSUSE_W163 == 2  ~ "No",
  SNSUSE_W163 == 99 ~ NA_character_   # Refused — say so, rather than let it fall through
)

Each arm reads “where this condition holds, use this label.” Note that comparisons like == 1 work perfectly well on a labelled column — the labels are metadata sitting on top of ordinary numbers, and R compares the numbers.

That third arm is optional, and worth writing anyway. case_when() sends anything it does not match to NA automatically, so deleting the line changes nothing about your results — 99 would become NA either way. What it changes is what a reader can see. Written out, the arm says we knew about the refusal code and we decided to treat it as missing. Left out, an identical result is indistinguishable from having never noticed the code was there. Make the decision visible; you will thank yourself in Project 1 when someone asks how you handled refusals.2

From text to factor

case_when() hands back plain text, not a factor. So its output gets piped one step further:

case_when(...) |> factor(levels = c("No", "Yes"))

Following four respondents through the two steps:

SNSUSE_W163 (raw) 1 2 1 99
after case_when() "Yes" "No" "Yes" NAplain text
after factor() Yes No Yes <NA>a factor

Read the |> exactly as you always have: take what case_when() produced, then hand it to factor().

Why bother converting to a factor at all? Because levels lets you pin the order of the categories, and that order isn’t cosmetic — it’s the order tbl_summary() prints the columns in, and later, in regression, the first level becomes the reference group. Leave levels off and R sorts alphabetically, which would give you No then Yes here by luck, and the wrong order the moment your categories are Low / Medium / High.

Job 2 · The demographics

Now look at what Pew attached to F_AGECAT:

Stored value Pew’s label
1 “18-29”
2 “30-49”
3 “50-64”
4 “65+”
99 “Refused”

Those labels are already exactly what you’d want in a table. So is “A woman”, so is “College graduate+”. There’s nothing to improve by retyping them — and across five variables you’d be retyping more than twenty categories, every one a chance for a typo.

haven::as_factor() does it in one move: it promotes each label into a factor level, keeping Pew’s own wording and Pew’s own order.

So why two different methods?

This is the question to hold on to, because you’ll face it on every labelled dataset you ever open. It comes down to one thing:

Do you want the survey’s labels, or your own?

  • Your own → case_when(). You’re writing new labels, so you spell out the mapping. Use it when the survey’s wording is too long for a table, when you want to collapse several categories into one, or when you need labels the survey never had.
  • The survey’s → as_factor(). The labels are already right, so you just promote them. It’s shorter, and it can’t introduce a typo, because you never retype a category.

Here the stratifier needed your labels (“Yes, I use social media sites” would have become a column header) and the demographics needed Pew’s (“18-29” is already perfect). Same goal — a factor with clean categories — reached two ways because the starting point differed.

One more piece for the demographics. as_factor() promotes every label, including 99 = "Refused" — which would then sit in your table as a legitimate category. Two functions handle that:

  • na_if(.x, 99) turns the 99s into NA before the conversion, so Refused never becomes a level.
  • fct_drop() removes the now-empty Refused level afterwards. (Converting first leaves the level defined but unused; fct_drop() tidies it away so it can’t show up as a 0 row.)

And since all five demographics need identical treatment, across() applies it to the lot rather than making you write the same line five times.

The M04 Module listed across() among the tools you’d “meet when you need them.” This is that moment — it’s the first time you actually write one.

The idea is small: apply the same transformation to several columns at once. It takes two arguments — which columns, and what to do to each:

across( c(F_AGECAT, F_GENDER, ...),        # which columns
        ~ haven::as_factor(na_if(.x, 99)) ) # what to do to each one

The ~ starts a little throwaway function, and .x is a placeholder meaning “whichever column we’re on right now.” R runs that line once per column, substituting each in turn — so the code above is five conversions written once.

Without it you’d write the same line five times, differing only in the variable name — which works, and is exactly how a typo gets into one of the five without you noticing.

Full reference: dplyr.tidyverse.org/reference/across.html.

Write it

Both jobs go in a single mutate(). Add it to the recode chunk in your skeleton’s # Wrangling section — same drill as before: write the code in your skeleton, checking against the tabs.

pew <- pew_selected |>
  ___( # the dplyr verb that adds/edits columns
    # 1. The stratifier: your labels, so you write the mapping
    any_social_media = ___( # the verb that maps values to labels
      SNSUSE_W163 == 1  ~ "Yes",
      SNSUSE_W163 == 2  ~ "No",
      SNSUSE_W163 == 99 ~ NA_character_  # Refused — say so, rather than let it fall through
    ) |> factor(levels = c("No", "Yes")),

    # 2. The demographics: Pew's labels, so you just promote them
    across(
      c(F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL),
      ~ haven::as_factor(na_if(.x, 99)) |> fct_drop()
    )
  )

Two blanks, both recall:

  • The dplyr verb that adds or edits columns → mutate.
  • The verb that maps values to labels, one arm per rule → case_when.

The across() line is written for you — it’s the “Pew’s labels” path from above, applied to all five demographics at once.

pew <- pew_selected |>
  mutate(
    # 1. The stratifier: your labels, so you write the mapping
    any_social_media = case_when(
      SNSUSE_W163 == 1  ~ "Yes",
      SNSUSE_W163 == 2  ~ "No",
      SNSUSE_W163 == 99 ~ NA_character_  # Refused — say so, rather than let it fall through
    ) |> factor(levels = c("No", "Yes")),

    # 2. The demographics: Pew's labels, so you just promote them
    across(
      c(F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL),
      ~ haven::as_factor(na_if(.x, 99)) |> fct_drop()
    )
  )

Run it. pew appears in your Environment — and now you check it, because a recode you don’t verify is a recode you don’t trust. Two ways, from the most direct to the most visual.

1 · Did each value map where I intended? The most direct check is a two-way count() of the original column against the recoded one — it shows the mapping itself, not just the end result:

pew |> count(SNSUSE_W163, any_social_media)

You should see exactly three rows — 1 [Yes…] → Yes, 2 [No] → No, and 99 [DK/Refused] → NA — proof that every code landed where the codebook said it should. (Run the same two-way count on any recode you’re unsure of.)

2 · Do the variables look the way you intended? Re-inspect through the same two lenses from Step 4 — now on your recoded columns — and hold them against the “before”:

pew_analysis <- pew |>
  select(
    any_social_media,
    F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL
  )

# codebook lens
pew_analysis |>
  look_for() |>
  convert_list_columns_to_character() |>
  gt()

# distribution lens
pew_analysis |> skim()

Before vs. after · did the recode do what you intended?

The two lenses tell the same story from different angles — read each against Step 4’s:

  • The codebook looks different in three ways — all expected. (1) Type: col_type flips from dbl+lbl to fct — these are factors now, not labelled numbers. (2) Where the labels live: the category text has moved columns — it left value_labels (now blank) and reappeared under levels, because a factor stores its categories as its levels. any_social_media shows up as a new factor with No; Yes levels. (3) The missing count rises: each demographic read missing = 0 before but a small non-zero number now. That is not damage — before, each 99 = Refused was a valid labelled value, so nothing counted as missing; na_if(., 99) turned those 99s into genuine NA. A rising missing count here is precisely the sign that the sentinel is finally being treated as missing, exactly as you intended.
  • The distribution lost the 99. In the skim, the demographics and any_social_media sit under Variable type: factor with real text labels in top_counts and no 99 anywhere — where the before skim showed numerics with a p100 of 99.

Those two checks are enough for today. As your skills grow, though, there’s a next-level habit worth knowing about — one that turns “I looked and it seemed fine” into a check the computer runs for you.

As your skills grow · the stopifnot() guardrail

Eyeballing only catches what you remember to look for. Professional analysts add a few stopifnot() assertions that make a recode fail loudly the instant an assumption breaks, instead of quietly passing a bad value downstream.

stopifnot() takes one or more logical tests. If every test is TRUE, nothing happens and your script runs on; if any is FALSE, it halts with an error naming the failed check. You’d drop something like this right after your recode:

stopifnot(
  # the stratifier only ever takes the two intended levels -- identical(), not
  # all(x == y), which can pass on a zero-length or recycled comparison
  identical(levels(pew$any_social_media), c("No", "Yes"))
)

Now the recode can’t fail silently: if a future wave codes Refused as 98, or a typo pushes a value out of range, the script stops here instead of quietly poisoning your Table 1.

You don’t need this today, and it isn’t expected on your group project either. File it away as something to grow into — the kind of thing worth adding once you’re maintaining data-prep code that gets re-run, by you months later or by someone else entirely.

Checkpoint 5 · Recoded data is verified

pew exists in your Environment, and you’ve verified the recode — not just run it: the two-way count() shows 1 → Yes, 2 → No, 99 → NA; your look_for() codebook shows the demographics now as factors (their value labels became levels); and your after-skim shows text-label top_counts with the 99 gone. You could hand this pipeline to someone else and show it’s correct.


Step 6 · Build Table 1 with gtsummary

This is the lab’s main M05 payoff: the report-ready Table 1 you previewed back in Step 0.

Table 1 · demographics by social-media use

Five demographics, stratified by whether the respondent uses social media. Add it to your # Table 1 with gtsummary section — fill in the column selection, the stratifier, and the missing-row handling.

pew |>
  ___(F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL,
      any_social_media) |> # the five demographics and the stratifier
  tbl_summary(
    by = ___,        # the stratifier you built in Step 5
    missing = "___"  # keep an "Unknown" row wherever a variable has missing values
  ) |>
  add_overall(last = TRUE) |>
  as_gt() |>
  tab_header(
    title = md("**Table 1.** Characteristics of the Wave 163 analytic sample"),
    subtitle = md("By social-media use · *unweighted*")
  )

The first blank is the dplyr verb that keeps only the columns you name — the one you’ve used since M04: select. Then stratify by the indicator you derived in Step 5 — any_social_media (no quotes; it’s a column name). For missing, use "ifany" — the default, and the one the M05 pre-study argues for: suppressing the row doesn’t remove the missing data, it removes the reader’s ability to see it.

pew |>
  select(F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL,
         any_social_media) |>
  tbl_summary(
    by = any_social_media,
    missing = "ifany"
  ) |>
  add_overall(last = TRUE) |>
  as_gt() |>
  tab_header(
    title = md("**Table 1.** Characteristics of the Wave 163 analytic sample"),
    subtitle = md("By social-media use · *unweighted*")
  )

What each gtsummary move does

  • select() keeps the five demographics and the stratifier any_social_media. The demographics are the factors you converted in Step 5, so the table shows their text labels.

  • tbl_summary(by = any_social_media) produces one column per level of the stratifier — No and Yes. (The Overall column is not its doing; that comes from add_overall() below.)

  • missing = "ifany" keeps an “Unknown” row for any variable that has missing values — the 99 = Refused cases you converted in Step 5. They are part of the sample, and a table that hides them quietly changes every percentage’s denominator.

  • add_overall(last = TRUE) adds the Overall column. Without last = TRUE it lands first, before the stratified columns — this puts it where a reader expects it, after the groups it summarizes.

  • as_gt() |> tab_header() converts to a gt table and adds the title + subtitle. Note that the subtitle says unweighted: these are percentages of the respondents in this file, not estimates for U.S. adults.

  • md() wraps both strings — it tells gt to read them as Markdown rather than literal text, so **Table 1.** renders bold and *unweighted* renders italic. Without it you’d see the asterisks printed on the page. It’s the same Markdown you write in your .qmd prose, reaching one layer further into the table itself. (The M05 Module introduces it alongside tab_header().)

    Try it: drop the md() from the title, re-render, and look at what the table prints. That one comparison is worth more than the explanation.

Now look at the column headers and do the arithmetic. Overall reads N = 5,078, not the 5,097 you’ve been told all lab. The missing 19 are the people who refused SNSUSE_W163: with no answer to “do you use social media,” they have NA on the stratifier, and tbl_summary() drops rows whose by variable is missing — there’s no column to put them in. So the table describes 5,078 of the 5,097 panelists, and the 19 it sets aside are those refusers — the 99 = Refused cases you flagged in Step 4 and recoded to NA in Step 5. Reconciling a table’s N against the sample you started with is a habit worth keeping — an N you can’t explain is usually a wrangling step you didn’t know you took.

One thing this table is not: a picture of U.S. adults. Every percentage in it weights each respondent equally, and Pew’s panel is not a miniature of the country — which is exactly what the WEIGHT_W163 column you selected in Step 4 exists to fix. The survey-weights page rebuilds this same table with the weights applied, so you can see what changes — it is linked at the end of the lab.

Pair-and-share · which stratifier did you pick?

You stratified by any_social_media. Look at Table 1. Which demographic shows the largest descriptive difference between social-media users and non-users in this analytic sample? Show your neighbor — does theirs match? (Descriptive, and in this sample: these are unweighted percentages, and nothing here tests whether a difference would hold in the population.)

Checkpoint 6 · Report-ready Table 1 is rendered

Your rendered HTML shows Table 1 with:

  • A title and subtitle, with the subtitle flagging the numbers as unweighted
  • Five demographic variables — and no importance row
  • “No,” “Yes,” and “Overall” columns, in that order
  • Counts and percentages, with an “Unknown” row wherever respondents refused
  • An Overall N of 5,078, and you can say why it isn’t 5,097

That is the hands-on work complete. The reflection prose comes next, at home.


Lab debrief · 5 minutes

That is the last activity. Save your work and look up — we will spend the last five minutes pulling the lab together while it is fresh. The write-up is yours to finish at home.

Take one quiet minute to skim the questions below, then we’ll discuss as a group.

Lab debrief · what did we learn by doing?

  1. The sticking point. What was the single hardest moment in today’s lab — procurement? Importing .sav? The before/after skim? The recode? gtsummary syntax? What helped you move forward, or what would have helped?

  2. Labelled data. Today you saw that labelled columns (<dbl+lbl>) carry the codebook inside the file — variable labels, value labels, the 99 = Refused decoder. What’s the value of that for a researcher inheriting someone else’s dataset? And what did you have to do to those columns before tbl_summary() would print real category names instead of numbers?

  3. Checking your work. You inspected the data through two lenses before recoding and the same two after, plus a two-way count() showing exactly where each code landed. Which of those checks felt most worth the time? Where in your own project could a “before and after” check catch a silent error you’d otherwise miss?

  4. Reading your Table 1. Look across the No and Yes columns of the table you built. Where do social-media users and non-users differ most — age? education? party? In one sentence, what does your Table 1 say about who uses social media in this sample? Letting the columns tell that story is the whole reason you build a Table 1.


Step 7 · Reflect on what you did · at home

The analysis is done and the notebook runs. What remains is the prose — the part worth giving unhurried time to, which is why it is homework rather than the last rushed ten minutes of class.

Below your Table 1, write a short prose paragraph (2–3 sentences) reflecting on the workflow you just carried out — the process of turning a raw survey file into a finished table.

Be clear about what this paragraph is not. Do not interpret the substantive differences in your table — who is more likely to use social media, which age group stands out. That is a real and interesting question, but answering it needs tools you meet in Part 2, and these numbers are unweighted, so they describe the 5,078 people who answered rather than U.S. adults. This paragraph is about the process, not the findings.

What to include in your paragraph

  • The verification habit. You skimmed the data before and after recoding. Name one thing the before/after comparison let you confirm — or would have caught — such as the 99 Refused code leaving the demographics, or the labelled columns arriving as factors with real category text.
  • The documentation. You read the codebook and questionnaire yourself. What did that tell you that the data alone would not have — for example, why the SM11_ columns are blank for everyone who doesn’t use social media?

Two-to-three sentences is the right length. The point is to name the habits — verify your recodes, check before you combine, read the docs — that carry into every dataset you’ll ever touch.

Checkpoint 7 · Reflection prose is in

Your rendered HTML has a short Reflection section below the table (2–3 sentences) describing the before/after verification and what the documentation told you that the data alone would not have.


Final render and submit

You’re done with the analysis. One last loop to lock it in:

  1. Add your name to the YAML’s author: field — replace "Your Name" with your actual name
  2. Do a final render. Click Render or Cmd/Ctrl + Shift + K
  3. Open the rendered file — it renders right next to your .qmd, at PSY652_project/programs/m05_lab.html
  4. Read it end to end — open it in your browser (right-click → Open With → your browser of choice) and read the whole report as if a stranger were reading it
  5. Submit it to Canvas under “Lab 5 — Building Table 1”
  6. Commit and push your work. In GitHub Desktop, look at what is and is not in the Changes tab. Present: m05_lab.qmd and your documentation/pew_atp_w163_source.md. Absent: pew_atp_w163.sav and every file inside documentation/pew_atp_w163/ — Pew’s codebook, questionnaire, and methodology. That absence matters more today than usual: you obtained those files under terms you personally accepted, and .gitignore is keeping all of them off the internet for you. What gets versioned is your code and your note about where the data came from. Summarize in one line (Add M05 Table 1 from Pew ATP W163), Commit to main, then Push origin.

If any Pew file does appear in the Changes tab, stop and do not commit — tell your instructor. It means a .gitignore line isn’t matching, and it is far easier to fix before the file enters your history than after.

Double check

Before you leave today:

What you just did, in research terms

You procured a real public-use survey dataset, imported it from .sav, turned a survey’s sentinel codes and labelled columns into analysis-ready variables, verified every recode by inspecting the data before and after, and produced a report-ready Table 1. That’s the data-management and descriptive core of any quantitative paper, in a single lab session. From here, the inferential machinery (M06–M12) adds the Results section on top — but the Table 1, and the careful wrangling behind it, is the foundation everything rests on, and you can now produce one from any survey dataset you encounter.

Going further (optional)

Optional challenge — after class, any time

Your lab is complete once Table 1 renders and your Reflection is in. These self-paced pages extend it — none of them goes into your lab notebook, and none is graded. Nothing in it goes into your lab notebook.

Footnotes

  1. A sentinel value is an ordinary number a dataset reserves to flag a non-answer — here 99 means Refused, not the quantity ninety-nine. Because it is stored as a real number, every calculation treats it as data unless you first convert it to NA.↩︎

  2. Recall the sentinel value from Step 3 — an ordinary number a dataset reserves to flag a non-answer. Here 99 is Pew’s, meaning Don’t know / Refused / Web blank rather than a quantity of anything.↩︎