32  Describing a dataset

32.1 When to use it

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.

32.2 The data it needs

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

32.3 Build it

32.3.1 What is in this dataset — 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.

32.3.2 What the numbers look like — 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.

32.3.3 Stratified by a class variable

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.

32.3.4 The whole dictionary — 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.

32.4 Read it

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.

32.5 Pitfalls

  • 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.