Building Table 1 from a Real Survey

Lab · Module 5 · Wed Sep 16

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 over time, rather than a brand-new sample being drawn for each study. Pew fields one or two surveys a month, usually to a subsample of the panel rather than all 10,000 members; each survey administration is called a wave and focuses on a particular set of topics. Two real-world wrinkles keep a raw wave from perfectly mirroring the country: not everyone invited responds, and Pew sometimes deliberately oversamples smaller groups so there are enough of them to study on their own. Pew therefore supplies a wave-specific survey weight built through a multistep process that accounts for differential selection and nonresponse and then calibrates the responding sample to known U.S. population benchmarks. If interested, you can see what applying that weight changes in the Going-further section. (For the full story of how the ATP is built and maintained, see Pew’s overview of the American Trends Panel.)

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.

How the code on this page works

Unlike earlier labs, the code blocks on this page are read-only reference — there’s no Run button, and you can’t edit them here. You run everything in your own RStudio, against the file you download in Step 1.

That follows directly from what makes this lab different. The browser sandboxes on other pages work because their dataset ships with the site, already loaded and waiting for you. Pew’s data doesn’t work that way: access is granted to you, individually, after you register and agree to their terms, so the file lands in your own data/ folder and nowhere else. There is no shared copy for a sandbox to load — which is the point. From here on, most data you work with will arrive this way, and your project’s data/ folder, not a web page, is where your analysis lives.

Use the ✍️ / 💡 / 👀 tabs to draft and check your code, then paste it into m05_lab.qmd and run it there.


Step 0 · Set up your notebook

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 Going-deeper section 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

Many public-use survey datasets sit behind some version of a procurement workflow — an email signup, a data-use agreement, a checkbox affirming you’ll cite the source. The point of doing it once in lab is to get familiar with the process. You’ll discover that the workflow is straightforward; and you’ll have a baseline expectation when you do it again for your group projects.

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.

This split — data in data/, its provenance in documentation/ — is a habit worth building now; you’ll want exactly this structure for your group project.

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.

One more layer: what gets versioned. Your .gitignore carries the line documentation/*/, which means any subfolder of documentation/ stays local — so Pew’s codebook, questionnaire, and methodology never reach GitHub, exactly as data/ keeps the .sav off it. That is deliberate: those are Pew’s files, distributed to you under the terms you accepted, and re-posting them to a repo is not yours to do.

But a project that records nothing about where its data came from is its own problem. That is the job of the flat pew_atp_w163_source.md you just wrote: it sits outside the ignored subfolder, so it is tracked, and it travels with your code. The pattern generalizes — the provider’s files stay local; your notes about them get versioned — and it is what you should do for every source in your group project.

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 code chunk tabs below are a scratch space to draft your code. Once complete, paste into your .qmd file in RStudio.

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 — the tabs below are scratch space for drafting.

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"
    ) |> 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"
    ) |> 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 Going-deeper section at the end of this lab rebuilds this same table with the weights applied, so you can see what changes.

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 one Wednesday afternoon. 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.

Labelled data, and the two ways out of it — you will need this for Project 1

You will meet <dbl+lbl> and other labelled columns again in many .sav, .dta, and .sas7bdat files you open — including the Pew file your group project runs on. Today you used one way out of labelled-land. There is a second, and knowing which one a variable needs is the difference between a clean analysis and a quietly wrong one.

When… Use Because the meaning lives in the… Example
the label is the meaning as_factor() label1 is not substantively meaningful on its own; "A woman" is the category you want to analyze The five demographics — this is what you did today
the number is the meaning zap_labels() number3 really is a score of 3 Ratings, counts, and scales: anything you will average, correlate, or put on a numeric axis

as_factor() turns labelled values into factor levels. For an ordinary labelled variable like the Pew variables here, zap_labels() takes the other route: it removes the value-label dictionary and gives you the underlying numbers. A stored 3 is still 3; it simply stops carrying its "Somewhat important" label. SPSS variables with formally declared user-missing values are a special case; we will separate those below.

Here is why the second route matters: labelled numeric columns can still behave like numbers. mean() can run on them. cor() can run on them. ggplot() can draw them. Nothing necessarily errors — so nothing necessarily warns you that a special code such as 99 = Don't know/Refused/Web blank has come along for the ride.

The three SM11 items are scored 1–4. Ask the raw columns for their maximum:

pew_selected |>
  summarize(across(starts_with("SM11"), ~ max(.x, na.rm = TRUE)))
99 [Don't know/Refused/Web blank]   99 [Don't know/...]   99 [Don't know/...]

R even prints the label telling you that 99 is not part of the substantive scale — and still hands it back as the maximum. Now clean the sentinel code and remove the value labels:

pew_selected |>
  mutate(
    across(
      starts_with("SM11"),
      ~ zap_labels(na_if(.x, 99))
    )
  ) |>
  summarize(across(starts_with("SM11"), ~ max(.x, na.rm = TRUE)))
4   4   4

Our workflow is na_if() first, then zap_labels(). We send the documented sentinel code to missing while its meaning is still visible, then strip the value labels and continue with an ordinary numeric variable. For these Pew columns you could reverse those two operations and still obtain the same numeric result; this order is simply easier to audit.

And note what makes this dangerous rather than merely wrong: only 3 to 7 people refused each of these items. A mean computed on the raw column is off by only a few hundredths — small enough that it may not look suspicious in a table, but still wrong. The max() check makes the problem obvious, which is exactly why inspecting the range of a cleaned variable is worth doing every time.

The damage is not always subtle, either. Correlate the three items with the refusal codes still present and you get r between 0.45 and 0.54; clean them first and the same three items give 0.66 to 0.75. Fifteen stray values — sitting 95 points beyond the end of a 1–4 scale — are enough to move a correlation by about 0.2 without producing a single warning.

One pair of names to keep straight: zap_labels() (plural) removes the value labels — the codes-to-text dictionary. zap_label() (singular) removes the variable label — the descriptive label attached to the variable itself, often based on the survey question wording. In this workflow you usually want the plural: clear the value labels while keeping the variable label available for tools such as tbl_summary().

A third function you’ll find — and why it is different

Search for ways to handle missing codes in survey files and you will also encounter zap_missing(). It has a different job: it converts special missing-value representations into ordinary R NAs. That includes tagged missing values from SAS or Stata and formally declared user-missing values from SPSS.

SPSS can formally declare particular values or ranges as user-defined missing. That declaration is metadata stored separately from a value label. When a .sav file contains those declarations, read_sav() gives you two useful choices:

How you read it What you get
read_sav(file) — the default Formally declared SPSS user-missing values are converted to ordinary NA during import
read_sav(file, user_na = TRUE) The original missing codes and declarations are preserved in a special labelled_spss vector, so categories such as Refused and Don’t know can remain distinguishable
…then zap_missing() Converts those preserved special missings to ordinary NA when you are finished inspecting or reporting them

That middle option is useful when the distinction among missing categories matters. You might, for example, want to report how many respondents refused an item versus answered Don’t know before collapsing both to ordinary missingness for analysis.

Why Pew’s 99 is different

Pew’s 99 is not an SPSS-declared user-missing value in this file. Two things tell you so. First, what happened on import: formally declared SPSS user-missing values would already have become NA under the default read_sav(), but Pew’s 99 survived as an actual numeric value carrying the label "Don't know/Refused/Web blank" — and the column came back with zero NAs. Second, you can check the declarations directly: reading the file with user_na = TRUE and inspecting the column’s attributes shows its na_values and na_range are both empty. There is nothing there to zap.

That gives you the distinction to remember:

A value label documents what a code means. A missing-value declaration tells software to treat that code as missing.

Pew gave 99 the first kind of metadata but not the second. So na_if(.x, 99) is not a workaround for failing to use zap_missing(); it is the correct cleaning step for a variable encoded this way.

If you ever need to inspect a new SPSS file’s original missing-value declarations directly, read it with user_na = TRUE. Haven will then preserve declared missing values or ranges in the imported labelled_spss object. Once you have inspected or reported those distinctions, zap_missing() converts them to ordinary NAs.

The practical rule:

  • If the labels are the categories you want to analyze, use as_factor().
  • If the underlying numbers are the scores you want to analyze, first deal explicitly with any non-substantive codes, then use zap_labels().
  • If the file contains special missing values that you intentionally preserved during import, zap_missing() converts them to ordinary NAs.

That distinction will matter again in Project 1, because real survey files often arrive with exactly this mixture of numeric codes, human-readable labels, and missing-data conventions.

Going further (optional)

Optional challenge — after class, any time

Your lab is complete once Table 1 renders and your Reflection is in. This self-paced activity extends it with one more move from the M05 Module, on the data you already procured. Nothing in it goes into your lab notebook.

  • Challenge: build an index from the SM11 items → — Pew asked social-media users how important the sites are for three kinds of civic engagement. Combine those three columns into a single score per person. There is no code on that page — you get the goal, the facts you can’t guess, and the checks your answer has to pass. The worked solution is on its own page for when you’re done, or stuck.

Rebuild Table 1 with the survey weight

Your Table 1 counts every respondent once. That makes it a description of the people who answered Wave 163, not automatically a description of all U.S. adults. Pew therefore supplies WEIGHT_W163, the wave-specific survey weight used to make population estimates from this sample.

A useful way to think about the weight is relative influence. In the unweighted table, every respondent contributes exactly 1. In a weighted estimate, respondents contribute different amounts so that the sample better reflects the U.S. adult population on the dimensions Pew used in its weighting process. For Wave 163, that process begins with selection probabilities, adjusts for nonresponse and differential wave selection, calibrates to population benchmarks such as age, education, race and ethnicity, region, and party, and trims extreme weights to limit the loss of precision.

You selected WEIGHT_W163 back in Step 4 and then never used it. Here is what changes when you do.

The survey package works by first creating a survey-design object. The public Wave 163 file gives us Pew’s final analysis weight, but it does not give us the full internal design information Pew uses to reproduce its published margins of error. For the descriptive percentages below, we therefore create a weights-only design:

library(survey)

# ids = ~1 says that we are not supplying cluster identifiers.
# For this exercise, the object is being used to apply Pew's final weight
# to the descriptive estimates in the table.
pew_design <- svydesign(
  ids     = ~ 1,
  weights = ~ WEIGHT_W163,
  data    = pew |> filter(!is.na(any_social_media))
)

pew_design |>
  tbl_svysummary(
    by = any_social_media,
    include = c(F_AGECAT, F_GENDER, F_EDUCCAT, F_RACETHNMOD, F_PARTY_FINAL),
    missing = "ifany"
  ) |>
  add_overall(last = TRUE) |>
  as_gt() |>
  tab_header(
    title = md("**Table 1.** Characteristics of U.S. adults"),
    subtitle = md("American Trends Panel Wave 163 · *weighted estimates*")
  )

Compare it with your unweighted table. The percentages shift — modestly for many rows, more for some. Age gives a clean example: adults 18–29 are 15.2% of the respondents in the analytic sample but 19.8% of the weighted estimate, a +4.6 percentage-point shift. The weighted result is telling you that the responding sample’s age composition did not exactly match the population benchmark, so the final weight changes how much different respondents contribute.

Social-media use moves too — from 81.0% in the unweighted analytic sample to 79.5% in the weighted population estimate. The direction is worth noticing because you cannot predict it from one demographic margin alone. Although weighting increases the contribution of younger adults in this wave, the weight is calibrated across many dimensions simultaneously. Within every age band in these data, respondents receiving larger weights also report social-media use at somewhat lower rates. Those adjustments combine to move the overall estimate downward.

A survey weight is not a single story about who is “missing.” It is the end product of several adjustments working at once. Which way it moves a particular estimate is therefore an empirical question: calculate the weighted estimate rather than trying to infer its direction from one characteristic.

Notice that the title changed too. The unweighted table describes the 5,078 respondents in the analytic sample. The weighted table uses Pew’s final survey weight to estimate characteristics of U.S. adults. Those are different statistical targets, so the prose surrounding the table should say which one you mean.

Wave 163 was not literally a simple random sample. Pew recruited the ATP through probability sampling, used a stratified wave sample, oversampled non-Hispanic Black and non-Hispanic Asian adults in Wave 163, and then constructed a final weight through a multistep process. Pew’s methodology also reports that its sampling errors account for the effect of weighting.

The public-use file gives you WEIGHT_W163, which is enough to reproduce Pew-style weighted point estimates such as percentages and means. It does not, however, expose all of the internal information Pew used to calculate the official margins of sampling error. That means svydesign(ids = ~1, weights = ~WEIGHT_W163, ...) should not be read as “Pew’s full survey design.” It is a weights-only representation built from the information available in the public file.

For today’s purpose — seeing how weighting changes the descriptive Table 1 — that is exactly what we need. When we turn to confidence intervals in M07, you will work with a survey dataset that supplies the design variables needed for design-based standard errors as well as the weight.

Pew reports a ±1.6 percentage-point margin of sampling error for the full Wave 163 sample. Treat that published value as Pew’s result; do not try to reverse-engineer it from WEIGHT_W163 alone.

Weighting a chart, too

Tables are not the only place weights matter. For Project 1, your figures may make claims about U.S. adults, so the same distinction carries into visualization.

For a chart that is built from counts or proportions, ggplot2 can use a weight aesthetic. Without it, each respondent contributes 1. With it, each respondent contributes their survey weight:

pew_chart <- pew |>
  filter(!is.na(any_social_media), !is.na(F_AGECAT))

pew_chart |>
  ggplot(aes(x = F_AGECAT, fill = any_social_media)) +
  geom_bar(aes(weight = WEIGHT_W163), position = "fill") +
  scale_y_continuous(labels = scales::percent) +
  scale_fill_manual(values = c("No" = "#E8ECF1", "Yes" = "#4E5EAA")) +
  labs(
    title = "Social media use by age, weighted to U.S. adults",
    x = NULL, y = NULL, fill = "Uses social media"
  ) +
  theme_minimal(base_size = 13)

Delete aes(weight = WEIGHT_W163) and you have the unweighted version. You can also calculate the two sets of percentages directly:

pew_chart |>
  group_by(F_AGECAT) |>
  summarize(
    unweighted = 100 * mean(any_social_media == "Yes"),
    weighted   = 100 * weighted.mean(any_social_media == "Yes", WEIGHT_W163),
    .groups = "drop"
  ) |>
  mutate(shift = weighted - unweighted)
Age band Unweighted Weighted Shift
18-29 91.7% 89.6% -2.1 pts
30-49 88.6% 87.3% -1.3 pts
50-64 79.9% 77.9% -2.0 pts
65+ 64.0% 60.4% -3.7 pts

This is the within-age-band picture behind the table above: in this wave, the weighted estimate of social-media use is lower within every age band, with the largest shift among adults 65+ (-3.7 points). That does not mean “age caused the weighting correction.” It means the final weight is carrying information from several weighting dimensions at once, and the people receiving relatively more influence within each age group happen, in these data, to report social-media use at lower rates.

The rule to carry into Project 1: match the claim to the calculation. An unweighted chart describes the respondents you analyzed. A chart that applies Pew’s wave-specific weight is intended to estimate the corresponding pattern among U.S. adults. Make that distinction visible in the title, subtitle, caption, or methods note — a reader cannot infer it from the bars alone.

And keep one more distinction in reserve for M07: weighting a point estimate and estimating its uncertainty are separate jobs. The weight aesthetic above changes the estimate; it does not create a confidence interval or reproduce Pew’s official margin of error. You will learn that second job when we build confidence intervals from the ground up.

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.↩︎