dta <- hvtiRutilities::generate_survival_data(n = 200, seed = 42)
dim(dta)[1] 200 24
The first thing most of us do with a new SAS dataset is run PROC CONTENTS, then PROC MEANS. Neither one produces a table anybody will publish. You run them because you cannot decide what belongs in Table 1 until you know what you are holding: which variables are there, what type each one came in as, and how much of each is actually populated.
That is this recipe. It is the pre-analysis table, the one that runs before the Table 1 in the publication tables chapter has a shape. hvtiRutilities gives you both procedures under the names you already type, proc_contents() and proc_means(), plus data_dictionary(), which flattens the first into the one-page artifact you hand a collaborator.
Reach for it whenever a dataset is new to you, has just been re-extracted, or arrives with someone telling you it is “the same as last time.” Ten seconds of proc_contents() catches the variable that came back as character this quarter, or the one that is four-fifths missing, while it is still cheap to ask about.
Any data frame. Labels, when the columns carry them, are picked up and reported; unlabelled columns fall back to the variable name. We use generate_survival_data(), the same simulated cohort the publication tables chapter builds its Table 1 from, so you can read the two chapters against one dataset.
dta <- hvtiRutilities::generate_survival_data(n = 200, seed = 42)
dim(dta)[1] 200 24
proc_contents()proc_contents() prints what SAS prints: a header carrying the observation and variable counts, then one row per variable with its creation position, name, SAS type, format, and label. Next to those it adds three columns you would otherwise go looking for separately: class, the R type sitting behind the SAS one; n_unique, how many distinct values the column holds; and pct_missing, how much of it is empty.
proc_contents(dta)Observations: 200
Variables: 24
num variable type format label
1 8 age Num <NA> Age at surgery (years)
2 10 bmi Num <NA> Body mass index (kg/m2)
3 20 bypass_time Num <NA> Cardiopulmonary bypass time (min)
4 1 ccfid Char <NA> Patient ID
5 5 dead Num <NA> Death indicator (1=dead, 0=censored)
6 23 diabetes Char <NA> Diabetes mellitus
7 14 gfr_bs Num <NA> Baseline eGFR (mL/min/1.73m2)
8 11 hgb_bs Num <NA> Baseline hemoglobin (g/dL)
9 24 hypertension Char <NA> Hypertension
10 4 iv_dead Num <NA> Follow-up time to death (years)
11 3 iv_opyrs Num <NA> Observation interval (years) since origin_year
12 7 iv_reop Num <NA> Follow-up time to reoperation (years)
13 15 lvefvs_b Num <NA> Baseline LV ejection fraction (%)
14 16 lvmass_b Num <NA> Baseline LV mass (g)
15 17 lvmsi_b Num <NA> Baseline LV mass index (g/m2)
16 22 nyha_class Char <NA> NYHA functional class
17 2 origin_year Num <NA> Calendar year for iv_opyrs = 0
18 13 plate_bs Num <NA> Baseline platelet count (K/uL)
19 6 reop Num <NA> Reoperation (1=yes, 0=no)
20 9 sex Char <NA> Sex
21 19 stvold_b Num <NA> Baseline SV index - diastolic (mL/m2)
22 18 stvoli_b Num <NA> Baseline SV index - systolic (mL/m2)
23 12 wbc_bs Num <NA> Baseline WBC count (K/uL)
24 21 xclamp_time Num <NA> Aortic cross-clamp time (min)
class n_unique pct_missing
1 numeric 165 0.0
2 numeric 123 0.0
3 numeric 100 0.0
4 character 200 0.0
5 integer 2 0.0
6 factor 2 0.0
7 numeric 174 0.0
8 numeric 66 0.0
9 factor 2 0.0
10 numeric 184 0.0
11 numeric 183 0.0
12 numeric 31 83.5
13 numeric 149 0.0
14 numeric 191 0.0
15 numeric 1 0.0
16 ordered 4 0.0
17 integer 21 0.0
18 numeric 135 0.0
19 integer 2 0.0
20 factor 2 0.0
21 numeric 171 0.0
22 numeric 168 0.0
23 numeric 172 0.0
24 numeric 75 0.0
Variables come back sorted alphabetically, matching SAS’s Alphabetic List of Variables and Attributes. Pass order = "varnum" for creation order instead. The num column reports creation position either way, so the original order is always recoverable by eye.
Two columns repay a slow read. type is deliberately two-valued, Char or Num, because that is all SAS storage offers: sex is a factor in R and reports Char here, while class keeps the R truth alongside it. Then pct_missing, which is where the surprises live. Here 23 of the 24 variables are complete and iv_reop is 83.5% missing, which is exactly right for a reoperation follow-up time. It exists only for the 33 patients who had a reoperation. Missingness with a reason is structure; missingness you cannot account for is a question for whoever built the extract.
proc_means()proc_means() summarises the numeric columns you name, defaulting to SAS’s own default five statistics.
proc_means(dta, vars = c("age", "bmi", "gfr_bs")) variable label n mean std min max
1 age Age at surgery (years) 200 44.5890 14.595538 1.0 85.0
2 bmi Body mass index (kg/m2) 200 26.7885 4.750479 15.0 41.8
3 gfr_bs Baseline eGFR (mL/min/1.73m2) 200 76.1505 19.392901 25.9 120.0
Omit vars and every numeric column is analysed, the way PROC MEANS behaves with no VAR statement. stats = takes the SAS keywords you already know, including "median", "q1", "q3", "nmiss", "stderr", and any "pNN" percentile.
Quantiles are worth one note. They use stats::quantile(type = 2), which is the R equivalent of SAS’s QNTLDEF=5, not R’s own type = 7 default. On a large sample the two agree. On the small, even-numbered subgroups a clinical stratification hands you, they do not: for c(1, 2, 3, 4) the first quartile is 1.5 in SAS and 1.75 under R’s default. The median agrees, which is how the disagreement stays hidden.
Give it class = and you get the same table once per group, the CLASS statement by another name.
proc_means(dta, vars = c("age", "bmi"), class = "sex") sex variable label n mean std min max
1 Female age Age at surgery (years) 77 45.07143 14.800443 1.0 79.3
2 Male age Age at surgery (years) 123 44.28699 14.518428 8.4 85.0
3 Female bmi Body mass index (kg/m2) 77 27.05325 4.889747 17.4 41.8
4 Male bmi Body mass index (kg/m2) 123 26.62276 4.673729 15.0 39.1
Rows sort by analysis variable first, then by class level, so you read a variable’s groups down the page rather than hunting across. A factor class variable orders by its declared levels rather than alphabetically, matching SAS’s ORDER=INTERNAL, which is what keeps an ordered clinical scale in clinical sequence.
Check the n column against your cohort size. Rows with a missing value in any class variable are dropped, again matching SAS, so a total that comes up short tells you how many patients had no group to sit in. Here 77 and 123 add to 200, and nothing was dropped.
data_dictionary()data_dictionary() is proc_contents() in creation order with the SAS-storage columns dropped and a summary put in their place. Out go num, type, and format, which describe how a value is stored rather than what it is. In comes a compact summary giving min, median, and max for a numeric variable, the levels for a factor, the TRUE percentage for a logical. That leaves six columns: variable, label, class, n_unique, pct_missing, and summary.
data_dictionary(dta) variable label
ccfid ccfid Patient ID
origin_year origin_year Calendar year for iv_opyrs = 0
iv_opyrs iv_opyrs Observation interval (years) since origin_year
iv_dead iv_dead Follow-up time to death (years)
dead dead Death indicator (1=dead, 0=censored)
reop reop Reoperation (1=yes, 0=no)
iv_reop iv_reop Follow-up time to reoperation (years)
age age Age at surgery (years)
sex sex Sex
bmi bmi Body mass index (kg/m2)
hgb_bs hgb_bs Baseline hemoglobin (g/dL)
wbc_bs wbc_bs Baseline WBC count (K/uL)
plate_bs plate_bs Baseline platelet count (K/uL)
gfr_bs gfr_bs Baseline eGFR (mL/min/1.73m2)
lvefvs_b lvefvs_b Baseline LV ejection fraction (%)
lvmass_b lvmass_b Baseline LV mass (g)
lvmsi_b lvmsi_b Baseline LV mass index (g/m2)
stvoli_b stvoli_b Baseline SV index - systolic (mL/m2)
stvold_b stvold_b Baseline SV index - diastolic (mL/m2)
bypass_time bypass_time Cardiopulmonary bypass time (min)
xclamp_time xclamp_time Aortic cross-clamp time (min)
nyha_class nyha_class NYHA functional class
diabetes diabetes Diabetes mellitus
hypertension hypertension Hypertension
class n_unique pct_missing
ccfid character 200 0.0
origin_year integer 21 0.0
iv_opyrs numeric 183 0.0
iv_dead numeric 184 0.0
dead integer 2 0.0
reop integer 2 0.0
iv_reop numeric 31 83.5
age numeric 165 0.0
sex factor 2 0.0
bmi numeric 123 0.0
hgb_bs numeric 66 0.0
wbc_bs numeric 172 0.0
plate_bs numeric 135 0.0
gfr_bs numeric 174 0.0
lvefvs_b numeric 149 0.0
lvmass_b numeric 191 0.0
lvmsi_b numeric 1 0.0
stvoli_b numeric 168 0.0
stvold_b numeric 171 0.0
bypass_time numeric 100 0.0
xclamp_time numeric 75 0.0
nyha_class ordered 4 0.0
diabetes factor 2 0.0
hypertension factor 2 0.0
summary
ccfid 200 levels: PT00001, PT00002, PT00003, PT00004, PT00005, ...
origin_year 1998 / 2008 / 2018
iv_opyrs 1.06 / 7.87 / 14.99
iv_dead 0.25 / 4.1 / 13.98
dead 0 / 1 / 1
reop 0 / 0 / 1
iv_reop 0.04 / 1.29 / 9.92
age 1 / 44.75 / 85
sex 2 levels: Female, Male
bmi 15 / 26.65 / 41.8
hgb_bs 7.6 / 13 / 18
wbc_bs 1.5 / 7.35 / 15.53
plate_bs 50 / 225 / 447
gfr_bs 25.9 / 75.75 / 120
lvefvs_b 29.1 / 53.75 / 75
lvmass_b 60 / 184.4 / 377.2
lvmsi_b 40 / 40 / 40
stvoli_b 20.5 / 53.7 / 90.3
stvold_b 40 / 93.35 / 153.6
bypass_time 20 / 88.5 / 194
xclamp_time 13 / 57 / 118
nyha_class 4 levels: I, II, III, IV
diabetes 2 levels: No, Yes
hypertension 2 levels: No, Yes
That summary column is what makes the table hand-off-able, and it is also where degenerate variables announce themselves. lvmsi_b reads 40 / 40 / 40 with an n_unique of 1, so it is a constant. A constant costs a row in Table 1 and tells the reader nothing. Send this frame through write.csv() and you have the data-dictionary appendix a protocol or a data-sharing agreement asks for.
Three outputs, three questions.
proc_contents() answers what is here: names, types, labels, and how complete each column is. Read it top to bottom once, then read pct_missing on its own, column-first rather than row-first. That single pass is most of the value.
proc_means() answers what do the numbers look like: centre, spread, and range per variable, and per group once you add class =. Read min and max before you read the mean. Impossible values show up at the ends, and a mean is good at hiding one. The age minimum of 1 in this simulated cohort is the kind of thing you would query on real data.
data_dictionary() answers what do I hand someone else. It is the one of the three you save to a file, and the data governance chapter files it beside the manifest for exactly that reason. Read it column by column, with most of the attention on pct_missing and n_unique. High missingness flags a variable you cannot lean on without saying so. A distinct-value count that surprises you, two levels where you expected continuous or a thousand where you expected a category, usually means a typing or coding problem upstream, caught here before it reaches a model. The summary column is your sanity check on range: a negative age or an ejection fraction above 100 shows up immediately.
None of the three is a publication table. They are the reconnaissance you do before deciding what the publication table should contain, and Table 1 comes after.
pct_missing is NaN, not 0, when the frame has no rows. Filter a cohort down to nothing and every pct_missing comes back NaN, because mean(is.na(x)) over an empty vector is 0/0. The table looks broken and is in fact reporting honestly: the proportion of missing values among no values is undefined, and a 0 there would assert that nothing is missing. If you format the column for display, handle NaN explicitly rather than letting it print raw next to real percentages, and treat it as the signal it is that your filter removed everything.
class = groups on the stored value, not the printed one. Two values that render identically but differ underneath stay separate groups, and you get back two rows that look like duplicates of each other. A trailing space in a character code does it, and so does a numeric group variable whose values differ below print precision. The behaviour is correct, since grouping on the rendered string would quietly merge values that are genuinely distinct, but it will mislead you if you are diagnosing from printed output alone. When a group count looks doubled, run unique() or table() on the class variable and compare what you get to what the table showed you.