Course contents

Part IChapter 5R foundations and data

Data Frames and CSV Files

On this page
  1. 1. Rows and columns
  2. 2. Access a row, column, or cell
  3. 3. Matrices: a table where every cell has one type
  4. Optional: arrays, inverses and eigenvalues
  5. 4. Load a CSV into R
  6. 5. A data dictionary answers “what does this mean?”
  7. 6. Inspect before analyzing
  8. 7. Write and read files
  9. 8. Work with rows: subset, sort, combine
  10. Try it
  11. Checkpoint

1. Rows and columns

A data frame is the R equivalent of a spreadsheet table. Each row is an observation and each column is a variable. In our main dataset, one row represents one fabricated patient visit and the patient_id identifies the fabricated individual. Real EHR systems may contain multiple visits per patient, so “row = patient” must never be assumed without checking.

toy <- data.frame(
  patient_id = c("S0001", "S0002", "S0003"),
  age = c(27, 65, 43),
  sbp = c(116, 142, NA),
  site = c("North", "Mobile", "South")
)
toy

Output

  patient_id age sbp   site
1      S0001  27 116  North
2      S0002  65 142 Mobile
3      S0003  43  NA  South
nrow(toy)                 # How many observations?

Output

[1] 3
ncol(toy)                 # How many columns?

Output

[1] 4
names(toy)                # Which variables exist?

Output

[1] "patient_id" "age"        "sbp"        "site"      
A small made-up table with a header row of column names, three rows, a highlighted column (one variable), a highlighted row (one observation), one cell, and one missing value marked NA.
A small made-up table with a header row of column names, three rows, a highlighted column (one variable), a highlighted row (one observation), one cell, and one missing value marked NA.

Each row of a data frame is one observation, each column is one variable, and a cell holds one value, which may be missing (NA). In the output above, nrow() prints 3 and ncol() prints 4, and names() lists the four column names.

2. Access a row, column, or cell

Use dollar sign ($) to select a column by name. Use [row, column] to select positions.

toy$age

Output

[1] 27 65 43
toy[1, ]                  # First row, all columns

Output

  patient_id age sbp  site
1      S0001  27 116 North
toy[, "sbp"]              # All rows, sbp column

Output

[1] 116 142  NA
toy[2, "age"]             # One cell

Output

[1] 65
mean(toy$age)

Output

[1] 45

The comma is important: the part before it specifies rows, and the part after it specifies columns. In the output, toy[2, "age"] prints 65, the age in row 2, and toy[, "sbp"] prints 116, 142 and NA, the whole column with its missing value.

3. Matrices: a table where every cell has one type

A matrix is a rectangular table in which every cell has the same type, for example all numbers. A data frame lets each column have its own type; a matrix does not. You need matrices when you do arithmetic on whole tables, and several later chapters (for example 19, 23 and 25) hand a matrix of numbers to a function.

matrix() takes the values plus nrow and ncol. By default R fills the table column by column; byrow = TRUE fills it row by row.

m <- matrix(1:6, nrow = 2, ncol = 3)
m

Output

     [,1] [,2] [,3]
[1,]    1    3    5
[2,]    2    4    6
matrix(1:6, nrow = 2, byrow = TRUE)

Output

     [,1] [,2] [,3]
[1,]    1    2    3
[2,]    4    5    6
dim(m)                    # Rows, then columns

Output

[1] 2 3
nrow(m)

Output

[1] 2
ncol(m)

Output

[1] 3

The first matrix lists 1, 2 in the first column, then 3, 4, then 5, 6. The second one, with byrow = TRUE, reads 1, 2, 3 across the first row. dim() returns the two sizes in one vector: 2 rows and 3 columns.

Indexing works like a data frame: m[row, column], and leaving one side empty means “all”.

m[2, 3]                   # One cell: row 2, column 3

Output

[1] 6
m[1, ]                    # The whole first row

Output

[1] 1 3 5
m[, 2]                    # The whole second column

Output

[1] 3 4
m[, -2]                   # Everything except column 2

Output

     [,1] [,2]
[1,]    1    5
[2,]    2    6
m[2:1, ]                  # Rows in reverse order

Output

     [,1] [,2] [,3]
[1,]    2    4    6
[2,]    1    3    5
m[m > 3]                  # Logical index: the cells larger than 3

Output

[1] 4 5 6

A negative index leaves that row or column out, so m[, -2] keeps columns 1 and 3. m[2:1, ] asks for row 2 first and then row 1, which reverses the rows. A logical index such as m > 3 picks the cells where it is TRUE; the result is a plain vector (4, 5 and 6), read down the columns.

Rows and columns can have names. rownames() and colnames() set or read them, and dimnames() holds both as a list. Once a matrix has names, you can index by name.

rownames(m) <- c("a", "b")
colnames(m) <- c("x", "y", "z")
m

Output

  x y z
a 1 3 5
b 2 4 6
dimnames(m)

Output

[[1]]
[1] "a" "b"

[[2]]
[1] "x" "y" "z"
m["a", "y"]

Output

[1] 3

cbind() glues columns side by side and rbind() stacks rows. t() transposes, which swaps rows and columns.

rbind(m, c = c(10, 20, 30))      # Add a third row named c

Output

   x  y  z
a  1  3  5
b  2  4  6
c 10 20 30
cbind(m, w = c(100, 200))        # Add a fourth column named w

Output

  x y z   w
a 1 3 5 100
b 2 4 6 200
t(m)                             # 3 rows, 2 columns

Output

  a b
x 1 2
y 3 4
z 5 6

Ordinary arithmetic on a matrix works element by element: the cell in row 1, column 1 meets the cell in row 1, column 1. The operator %*% is different. It is true matrix multiplication, where each result cell is a row of the left matrix multiplied by a column of the right one and summed. The sizes must fit: a 2-by-3 matrix times a 3-by-2 matrix gives a 2-by-2 matrix.

m * 2                     # Every cell doubled

Output

  x y  z
a 2 6 10
b 4 8 12
m * m                     # Cell-by-cell product

Output

  x  y  z
a 1  9 25
b 4 16 36
m + c(10, 20)             # Shorter vector is recycled down the columns

Output

   x  y  z
a 11 13 15
b 22 24 26
m %*% t(m)                # Matrix product, 2 x 2

Output

   a  b
a 35 44
b 44 56

In m + c(10, 20) the shorter vector is reused, or recycled, to fill the matrix column by column: 10 is added to row 1 and 20 to row 2, in every column.

In m %*% t(m) the top-left cell is 1×1 + 3×3 + 5×5 = 35, which is the first row of m times itself. Plain m * m only squares each cell, so its top-left cell is 1.

apply() runs a function over every row or every column. The second argument says which: 1 means rows, 2 means columns.

apply(m, 1, sum)          # One sum per row

Output

 a  b 
 9 12 
apply(m, 2, mean)         # One mean per column

Output

  x   y   z 
1.5 3.5 5.5 
as.vector(m)              # Unroll the matrix into one vector, column by column

Output

[1] 1 2 3 4 5 6

A data frame whose columns are all numeric can be converted with as.matrix(). This is how the course data becomes a matrix for later chapters.

ehr <- readr::read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
vitals <- as.matrix(ehr[1:5, c("age", "sbp", "pulse")])
vitals

Output

     age sbp pulse
[1,]  55 120    79
[2,]  26 110    92
[3,]  63 133    65
[4,]  34 120    77
[5,]  71 141    89
apply(vitals, 2, mean)

Output

  age   sbp pulse 
 49.8 124.8  80.4 

If one column were text, as.matrix() would turn every cell into text, because a matrix can hold only one type. That is why you select the numeric columns first.

[!NOTE]

Optional: arrays, inverses and eigenvalues

These three tools are not needed to follow the next chapters, but you will meet their names.

An array is a matrix with a third (or fourth …) dimension, like a stack of tables. array() takes the values and a dim vector.

a <- array(1:12, dim = c(2, 2, 3))   # 2 rows, 2 columns, 3 layers
dim(a)

Output

[1] 2 2 3
a[, , 2]                              # The second layer

Output

     [,1] [,2]
[1,]    5    7
[2,]    6    8

solve() computes the inverse of a square matrix: multiplying a matrix by its inverse gives the identity matrix (ones on the diagonal, zeros elsewhere).

A <- matrix(c(2, 1, 1, 3), nrow = 2)
solve(A)

Output

     [,1] [,2]
[1,]  0.6 -0.2
[2,] -0.2  0.4
round(A %*% solve(A), 10)

Output

     [,1] [,2]
[1,]    1    0
[2,]    0    1

eigen() returns the eigenvalues and eigenvectors of a square matrix. It is closely related to principal component analysis (PCA) in chapter 23: PCA can be computed from the eigenvalues and eigenvectors of a covariance matrix, although R’s prcomp() uses a related method called singular value decomposition (SVD).

eigen(matrix(c(2, 1, 1, 2), nrow = 2))

Output

eigen() decomposition
$values
[1] 3 1

$vectors
          [,1]       [,2]
[1,] 0.7071068 -0.7071068
[2,] 0.7071068  0.7071068
Object Dimensions Cell types
vector 1 all one type
matrix 2 all one type
array 3 or more all one type
data frame 2 each column may have its own type

4. Load a CSV into R

The course CSV is already in the data folder. CSV means “comma-separated values”; it is a plain-text table. A CSV does not preserve Excel styling.

library(readr)
ehr <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
dim(ehr)                 # Number of rows, then columns

Output

[1] 600  10
names(ehr)

Output

 [1] "patient_id"     "visit_date"     "clinic_site"    "age"           
 [5] "sex"            "bmi"            "sbp"            "pulse"         
 [9] "family_history" "diabetes"      
head(ehr, 6)

Output

# A tibble: 6 x 10
  patient_id visit_date clinic_site   age sex     bmi   sbp pulse family_history
  <chr>      <date>     <chr>       <dbl> <chr> <dbl> <dbl> <dbl>          <dbl>
1 S0001      2025-01-10 South          55 F      20.4   120    79              0
2 S0002      2025-01-19 Mobile         26 M      22.7   110    92              0
3 S0003      2025-01-28 North          63 F      24.7   133    65              1
4 S0004      2025-02-06 South          34 F      26.3   120    77              0
5 S0005      2025-02-15 Mobile         71 M      27.6   141    89              0
6 S0006      2025-02-24 North          42 F      28.7   126    63              0
# i 1 more variable: diabetes <chr>
dplyr::glimpse(ehr)      # Types and sample values

Output

Rows: 600
Columns: 10
$ patient_id     <chr> "S0001", "S0002", "S0003", "S0004", "S0005", "S0006", "~
$ visit_date     <date> 2025-01-10, 2025-01-19, 2025-01-28, 2025-02-06, 2025-0~
$ clinic_site    <chr> "South", "Mobile", "North", "South", "Mobile", "North",~
$ age            <dbl> 55, 26, 63, 34, 71, 42, 79, 50, 21, 58, 29, 66, 37, 74,~
$ sex            <chr> "F", "M", "F", "F", "M", "F", "M", "F", "F", "M", "F", ~
$ bmi            <dbl> 20.4, 22.7, 24.7, 26.3, 27.6, 28.7, 29.7, 30.7, 31.9, 3~
$ sbp            <dbl> 120, 110, 133, 120, 141, 126, 145, 128, 111, 128, 112, ~
$ pulse          <dbl> 79, 92, 65, 77, 89, 63, 75, 87, 60, 72, 84, 58, 71, 84,~
$ family_history <dbl> 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0, 0, 1, 0, 0, 1, 0, 0~
$ diabetes       <chr> "No", "No", "No", "No", "No", "No", "No", "No", "No", "~

dim(ehr) prints 600 and then 10: the table has 600 rows (visits) and 10 columns.

The read_csv() function reads the file; ehr is an object in R’s memory. Changing ehr does not overwrite the original CSV until you explicitly save a new file.

Base R has its own reader, read.csv(). It needs no package and returns a plain data frame instead of a tibble. You will see it in older code and in code that avoids extra packages. Three of its arguments matter most:

  • header = TRUE says the first line holds column names. It is the default for read.csv(); use header = FALSE when the file has no header line.
  • stringsAsFactors = FALSE keeps text columns as plain text. This is also the default in current R; older code often set it explicitly.
  • na.strings lists the pieces of text that should become NA. The default is "NA" only, so other codes such as "missing" or -99 need to be named.
base_df <- read.csv("data/synthetic_ehr.csv", header = TRUE, stringsAsFactors = FALSE)
class(base_df)                                      # A plain data frame

Output

[1] "data.frame"
class(base_df$clinic_site)                          # Text stayed text

Output

[1] "character"
raw_text <- "id,sbp\nA,120\nB,missing\nC,-99"
read.csv(text = raw_text)$sbp                       # Default: text, codes not recognized

Output

[1] "120"     "missing" "-99"    
read.csv(text = raw_text, na.strings = c("missing", "-99"))$sbp

Output

[1] 120  NA  NA

The first two lines print "data.frame" and "character": with stringsAsFactors = FALSE the site names are plain text, not a factor. Without na.strings, the whole sbp column is read as text because of the word "missing", so the codes stay in it. With na.strings, those two entries become NA and the rest of the column is read as numbers.

5. A data dictionary answers “what does this mean?”

Column Meaning Type What could go wrong?
patient_id fabricated unique identifier character never average IDs
visit_date fabricated visit date date/text chronology can leak future data
clinic_site fictional site category site differences can matter
age years numeric impossible values
sex synthetic F/M category category incomplete representation in real records
bmi kg/m² numeric missing or outlier
sbp systolic blood pressure, mmHg numeric erroneous unit or missing
pulse beats/minute numeric missing
family_history fictional 0/1 flag numeric 0/1; factor() later 0 may mean “unknown” in real life
diabetes fully known, fabricated outcome category real “not diagnosed” is not confirmed “No”

Example: check the dictionary against what R actually read. sapply(ehr, class) returns the type R gave each column, and you compare it with the Type column above.

sapply(ehr, class)

Output

    patient_id     visit_date    clinic_site            age            sex 
   "character"         "Date"    "character"      "numeric"    "character" 
           bmi            sbp          pulse family_history       diabetes 
     "numeric"      "numeric"      "numeric"      "numeric"    "character" 

All ten columns match the dictionary. visit_date came in as "Date", a real date type, age, bmi, sbp, pulse and family_history are "numeric", and the rest are "character". Note that family_history is stored as a number although the dictionary calls it a 0/1 flag; chapter 7 and later chapters turn such flags into categories with factor() when needed.

6. Inspect before analyzing

summary(ehr$age)

Output

   Min. 1st Qu.  Median    Mean 3rd Qu.    Max. 
  18.00   34.00   50.50   50.48   67.00   83.00 
table(ehr$diabetes, useNA = "ifany")

Output

 No Yes 
546  54 
colSums(is.na(ehr))

Output

    patient_id     visit_date    clinic_site            age            sex 
             0              0              0              0              0 
           bmi            sbp          pulse family_history       diabetes 
            35             20             31              0              0 
anyDuplicated(ehr$patient_id)        # 0 means no duplicate fabricated IDs

Output

[1] 0

The table() line prints No 546 and Yes 54, so 54 of the 600 visits have diabetes. colSums(is.na(ehr)) shows 35 missing bmi, 20 missing sbp and 31 missing pulse, and anyDuplicated() prints 0, so no ID repeats.

Never assume read_csv guessed a type correctly. A number stored as text, an empty field interpreted unexpectedly, or duplicate visit records can change your answer.

7. Write and read files

write_csv() saves a table as a comma-separated file; base R’s write.csv() does the same job. Use a temporary file while practicing: tempfile() returns a path in a temporary folder that R cleans up, so nothing lands in your project.

out_csv <- tempfile(fileext = ".csv")
write_csv(toy, out_csv, na = "")
readLines(out_csv)

Output

[1] "patient_id,age,sbp,site" "S0001,27,116,North"     
[3] "S0002,65,142,Mobile"     "S0003,43,,South"        

na = "" writes missing values as empty fields. (Base write.csv() also needs row.names = FALSE to leave out the automatic row numbers.) readLines() shows the raw text: the third patient’s SBP is an empty field between two commas.

Not every table is comma-separated. A .txt file often separates columns with a tab ("\t"). write.table() is the general writer, and read.delim() (or the general read.table()) reads tab-separated text back.

out_txt <- tempfile(fileext = ".txt")
write.table(ehr[1:4, c("patient_id", "age", "sbp")], out_txt,
            sep = "\t", quote = FALSE, row.names = FALSE)
readLines(out_txt)

Output

[1] "patient_id\tage\tsbp" "S0001\t55\t120"       "S0002\t26\t110"      
[4] "S0003\t63\t133"       "S0004\t34\t120"      
read.delim(out_txt)

Output

  patient_id age sbp
1      S0001  55 120
2      S0002  26 110
3      S0003  63 133
4      S0004  34 120
read.table(out_txt, header = TRUE, sep = "\t")

Output

  patient_id age sbp
1      S0001  55 120
2      S0002  26 110
3      S0003  63 133
4      S0004  34 120

read.delim() already assumes tabs and a header line. With read.table() you must say header = TRUE and sep = "\t" yourself; the two calls print the same table.

To keep an R object exactly as it is, including column types, save it in R’s own format. save() writes one or more named objects to an .RData file, and load() brings them back under the same names. saveRDS() writes a single object, and readRDS() returns it, so you choose the name.

small <- ehr[1:3, c("patient_id", "age", "sbp")]
n_rows <- nrow(ehr)

rdata_file <- tempfile(fileext = ".RData")
save(small, n_rows, file = rdata_file)
rm(small, n_rows)                          # Delete them from memory
load(rdata_file)                           # They come back with the same names
c(exists("small"), exists("n_rows"))

Output

[1] TRUE TRUE
rds_file <- tempfile(fileext = ".rds")
saveRDS(small, rds_file)
small_again <- readRDS(rds_file)
all.equal(small, small_again)

Output

[1] TRUE

load() restores the objects small and n_rows, so both exists() calls are TRUE. all.equal() is TRUE, so the RDS copy matches the original. (identical() is a stricter test that also compares hidden attributes, which a table read by read_csv() carries; all.equal() is the safer check here.) Prefer saveRDS() for sharing one table: whoever reads it can pick any name, and nothing in their session is overwritten by surprise. In your own project, write results into the outputs/ folder; never save real clinic information into practice files without proper governance.

8. Work with rows: subset, sort, combine

Base R has a function for choosing rows, subset(). Its second argument is a condition, written without ehr$ in front of the column names, and select picks columns.

ehr_df <- as.data.frame(ehr)      # a plain data frame, like read.csv() returns
old <- subset(ehr_df, age >= 80 & !is.na(sbp), select = c(patient_id, age, sbp))
nrow(old)

Output

[1] 36
head(old, 4)

Output

   patient_id age sbp
16      S0016  82 123
32      S0032  80 128
41      S0041  83 147
57      S0057  81 137

subset() keeps only rows where the condition is TRUE, so a row whose condition is NA would be dropped without a message. Here !is.na(sbp) states openly that we want rows with a recorded sbp. Notice that the row numbers on the left are not 1, 2, 3, 4: they are the original row numbers from ehr.

order() returns the row positions that would sort a column. Put it inside [ , ] to sort a table, and add decreasing = TRUE for largest first.

by_age <- ehr_df[order(ehr_df$age, decreasing = TRUE), c("patient_id", "age", "sex")]
head(by_age, 4)

Output

    patient_id age sex
41       S0041  83   F
107      S0107  83   M
173      S0173  83   F
239      S0239  83   F

To join tables, cbind() places data frames side by side (same number of rows) and rbind() stacks them (same column names). The dplyr function bind_rows() stacks too. Both match columns by name; the difference is what happens when the names do not agree.

ids <- data.frame(patient_id = c("S1", "S2"))
ages <- data.frame(age = c(40, 52))
both <- cbind(ids, ages)
both

Output

  patient_id age
1         S1  40
2         S2  52
more <- data.frame(patient_id = "S3", age = 61)
rbind(both, more)

Output

  patient_id age
1         S1  40
2         S2  52
3         S3  61
dplyr::bind_rows(both, more)

Output

  patient_id age
1         S1  40
2         S2  52
3         S3  61

cbind() gives a table with two columns and two rows, and rbind() and bind_rows() give the same three rows here. Now stack a table that lacks the age column:

no_age <- data.frame(patient_id = "S4")
try(rbind(both, no_age))

Output

Error in rbind(deparse.level, ...) : 
  numbers of columns of arguments do not match
dplyr::bind_rows(both, no_age)

Output

  patient_id age
1         S1  40
2         S2  52
3         S4  NA

rbind() stops with an error (“numbers of columns of arguments do not match”), while bind_rows() keeps every column and fills the missing age with NA. That is convenient, but check that a silent NA is what you want.

Row names. A data frame can carry row names, as in the old table above, where they are the leftover row numbers. Avoid relying on them. They change when you filter or sort, many tools drop them, and they are not a column you can filter on. Keep identifying information in a real column such as patient_id. If you want clean row numbers, reset them:

rownames(old) <- NULL
head(old, 3)

Output

  patient_id age sbp
1      S0016  82 123
2      S0032  80 128
3      S0041  83 147

Try it

  1. Load the synthetic CSV and find its number of rows and columns.
Show solution to question 1

Answer: The file has 600 rows and 10 columns.

Step by step

  1. Read the file with read_csv() and store it in an object.
  2. Ask for its size with dim().
ehr_try <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
dim(ehr_try)

Output

[1] 600  10

3. dim() prints the number of rows first and the number of columns second.

  1. Print the first five BMI values.
Show solution to question 2

Answer: The first five BMI values are 20.4, 22.7, 24.7, 26.3 and 27.6.

Step by step

  1. Pick the column with $: ehr$bmi.
  2. Take the first five values with head(..., 5).
head(ehr$bmi, 5)

Output

[1] 20.4 22.7 24.7 26.3 27.6

3. Each printed number is one patient’s BMI in kg/m², in file order. An NA in this list would mean a missing value.

  1. Count missing BMI values.
Show solution to question 3

Answer: 35 BMI values are missing.

Step by step

  1. is.na(ehr$bmi) gives TRUE for each missing value and FALSE otherwise.
  2. sum() adds the TRUEs, because TRUE counts as 1.
sum(is.na(ehr$bmi))

Output

[1] 35

3. The printed number is how many BMI cells are empty. Dividing it by nrow(ehr) would give the share missing.

  1. In one sentence, explain the difference between a patient ID and a clinical feature.
Show solution to question 4

Answer: A patient ID is a label that names a row, while a clinical feature is a measurement or fact about the patient that can help describe or predict an outcome; averaging IDs or using them to infer disease is meaningless.

Step by step

  1. Ask what the column means. patient_id names who the row is about; age, bmi and sbp describe the person.
  2. Ask whether arithmetic on it makes sense. The mean of age is meaningful; the mean of IDs is not.
  3. Ask whether it could carry a hidden pattern (for example IDs assigned in order of enrolment). Treat the ID as a key for joining and checking, not as evidence about health.
  1. Compare the number of rows with the number of unique IDs.
Show solution to question 5

Answer: There are 600 rows and 600 unique IDs, so every ID appears once and each row is one patient here.

Step by step

  1. Count rows with nrow().
  2. Count the different IDs with unique() and length().
n_ids <- length(unique(ehr$patient_id))
c(rows = nrow(ehr), unique_ids = n_ids)

Output

      rows unique_ids 
       600        600 

3. When the two numbers are equal, no ID repeats, so each row here is a different patient. If rows were more than unique IDs, some patients would have several visits and “row = patient” would be wrong.

  1. Build a 3-by-2 matrix from the numbers 1 to 6, filled by row. Name its columns a and b, then find the sum of each row with apply().
Show solution to question 6

Answer: The row sums are 3, 7 and 11.

Step by step

  1. Build the matrix with matrix(1:6, nrow = 3, byrow = TRUE).
  2. Name the columns with colnames().
  3. Sum across each row with apply(m6, 1, sum); the 1 means rows.
m6 <- matrix(1:6, nrow = 3, byrow = TRUE)
colnames(m6) <- c("a", "b")
m6

Output

     a b
[1,] 1 2
[2,] 3 4
[3,] 5 6
apply(m6, 1, sum)

Output

[1]  3  7 11

4. The matrix reads (1, 2), (3, 4), (5, 6) across its rows, so the row sums are 3, 7 and 11.

  1. Convert the age, bmi and sbp columns of the first ten rows of ehr into a matrix and get the mean of each column, skipping missing values.
Show solution to question 7

Answer: The column means are about 49.9 for age, 27.6 for bmi and 126.2 for sbp.

Step by step

  1. Select the first ten rows and the three numeric columns, then convert with as.matrix().
  2. Use apply(..., 2, mean, na.rm = TRUE); the 2 means columns, and na.rm = TRUE is passed on to mean().
vit <- as.matrix(ehr[1:10, c("age", "bmi", "sbp")])
apply(vit, 2, mean, na.rm = TRUE)

Output

   age    bmi    sbp 
 49.90  27.61 126.20 

3. Each mean uses only the observed values of its own column, and none of these three columns has a missing value in rows 1 to 10, so all ten values count in each mean. With a missing cell, that column would be averaged over fewer than ten numbers.

  1. Read the text "id,score\nA,5\nB,n/a" with read.csv(text = ...) so that "n/a" becomes NA.
Show solution to question 8

Answer: Pass na.strings = "n/a"; the score column then holds 5 and NA and is numeric.

Step by step

  1. Without na.strings, "n/a" is ordinary text, so the whole score column would be read as text.
  2. Name the code in na.strings.
read.csv(text = "id,score\nA,5\nB,n/a", na.strings = "n/a")

Output

  id score
1  A     5
2  B    NA

3. The second score prints as NA, and the first stays 5. str() on the result would show score as numeric.

  1. Write the first four rows of ehr to a tab-separated temporary file with write.table(), read it back with read.delim(), and check the number of rows.
Show solution to question 9

Answer: read.delim() returns 4 rows, so the four rows survived the round trip.

Step by step

  1. Make a temporary path with tempfile().
  2. Write with sep = "\t", quote = FALSE and row.names = FALSE.
  3. Read it back with read.delim(), which expects tabs and a header, and count the rows.
f_txt <- tempfile(fileext = ".txt")
write.table(ehr[1:4, ], f_txt, sep = "\t", quote = FALSE, row.names = FALSE)
back <- read.delim(f_txt)
nrow(back)

Output

[1] 4

4. The four rows come back, so the round trip kept the table’s rows.

  1. Save ehr with saveRDS() to a temporary file and read it back; test that the copy is equal with all.equal().
Show solution to question 10

Answer: all.equal() prints TRUE, so the saved copy has the same contents as ehr.

Step by step

  1. Write the object with saveRDS(ehr, path).
  2. Read it with readRDS(path) and compare with all.equal().
f_rds <- tempfile(fileext = ".rds")
saveRDS(ehr, f_rds)
all.equal(ehr, readRDS(f_rds))

Output

[1] TRUE

3. TRUE means the contents match. identical() can print FALSE for a table made by read_csv(), because it also compares hidden attributes; use all.equal() to compare contents.

  1. Use subset() to get patients aged 30 or younger with a non-missing bmi, then sort that result by bmi from largest to smallest with order(). Report the largest bmi.
Show solution to question 11

Answer: The largest bmi among patients aged 30 or younger with a recorded bmi is 43.7 (111 patients qualify).

Step by step

  1. Filter with subset(), stating the missing-value rule openly with !is.na(bmi).
  2. Sort with order(..., decreasing = TRUE) inside [ , ].
  3. Print the first value of the sorted bmi column.
young <- subset(ehr_df, age <= 30 & !is.na(bmi))
young_sorted <- young[order(young$bmi, decreasing = TRUE), ]
nrow(young)

Output

[1] 111
head(young_sorted$bmi, 1)

Output

[1] 43.7

4. After sorting from largest to smallest, the first value is the maximum BMI among these patients.

Checkpoint

You can read a CSV, inspect names and types, select rows and columns, build and index a matrix, move tables in and out of files, and save an output without changing source data.