Lab 5: Data Frames and Matrices

BI 255 · Bethune-Cookman University · Fall 2026

Author

Dr. Rosie Stanbrook-Buyer

Published

August 28, 2026


From Vectors to Tables: NBA Roster Data

So far we’ve worked with individual vectors — one column of data at a time. Real biological and sports data comes in tables: multiple columns with different types of information about the same subjects.

Today we learn data frames (R’s version of a spreadsheet) and matrices (tables of numbers only).

NoteLearning Objectives

By the end of this lab you will be able to:

  1. Create a data frame using data.frame()
  2. Explore a data frame with str(), head(), tail(), dim(), nrow(), ncol()
  3. Access columns with $ and [ ]
  4. Create a matrix using matrix()
  5. Understand the key difference between data frames and matrices

Part 1: What Is a Data Frame?

A data frame is a table where: - Each row is one observation (e.g., one player) - Each column is one variable (e.g., points, rebounds, position) - Each column can have a different data type (numbers AND text can live together)

This is exactly like a spreadsheet, and it’s the most important data structure in R for biological data.

# Let's build a data frame for the 2025-26 Golden State Warriors roster
warriors <- data.frame(
  player     = c("Stephen Curry", "Klay Thompson", "Draymond Green",
                 "Andrew Wiggins", "Kevon Looney", "Jonathan Kuminga",
                 "Moses Moody", "Brandin Podziemski", "Gary Payton II",
                 "Chris Paul"),
  jersey_num = c(30, 11, 23, 22, 5, 00, 4, 2, 8, 3),
  position   = c("PG", "SG", "PF", "SF", "C", "SF", "SG", "PG", "SG", "PG"),
  age        = c(37, 34, 34, 29, 28, 22, 22, 21, 32, 40),
  ppg        = c(26.4, 17.9, 8.5, 14.2, 6.3, 16.1, 9.4, 12.8, 7.1, 9.0),
  is_allstar = c(TRUE, TRUE, TRUE, FALSE, FALSE, FALSE,
                 FALSE, FALSE, FALSE, TRUE)
)

Part 2: Exploring a Data Frame

Once you have a data frame, these functions help you understand its contents immediately:

# How big is it? (rows, columns)
dim(warriors)
[1] 10  6
nrow(warriors)   # Number of rows (players)
[1] 10
ncol(warriors)   # Number of columns (variables)
[1] 6
# Show the first 6 rows
head(warriors)
            player jersey_num position age  ppg is_allstar
1    Stephen Curry         30       PG  37 26.4       TRUE
2    Klay Thompson         11       SG  34 17.9       TRUE
3   Draymond Green         23       PF  34  8.5       TRUE
4   Andrew Wiggins         22       SF  29 14.2      FALSE
5     Kevon Looney          5        C  28  6.3      FALSE
6 Jonathan Kuminga          0       SF  22 16.1      FALSE
# Show the last 6 rows
tail(warriors)
               player jersey_num position age  ppg is_allstar
5        Kevon Looney          5        C  28  6.3      FALSE
6    Jonathan Kuminga          0       SF  22 16.1      FALSE
7         Moses Moody          4       SG  22  9.4      FALSE
8  Brandin Podziemski          2       PG  21 12.8      FALSE
9      Gary Payton II          8       SG  32  7.1      FALSE
10         Chris Paul          3       PG  40  9.0       TRUE
# The most important function: str() shows the STRucture
str(warriors)
'data.frame':   10 obs. of  6 variables:
 $ player    : chr  "Stephen Curry" "Klay Thompson" "Draymond Green" "Andrew Wiggins" ...
 $ jersey_num: num  30 11 23 22 5 0 4 2 8 3
 $ position  : chr  "PG" "SG" "PF" "SF" ...
 $ age       : num  37 34 34 29 28 22 22 21 32 40
 $ ppg       : num  26.4 17.9 8.5 14.2 6.3 16.1 9.4 12.8 7.1 9
 $ is_allstar: logi  TRUE TRUE TRUE FALSE FALSE FALSE ...
Tipstr() Is Your Best Friend

Always run str() on a new dataset. It tells you: - How many rows and columns there are - The name and type of every column - A preview of the first few values

In biology, the first thing you do with any new dataset is str() then head().

# summary() gives quick statistics for every column
summary(warriors)
       player     jersey_num         position       age            ppg        
 Length   :10   Min.   : 0.00   Length   :10   Min.   :21.0   Min.   : 6.300  
 N.unique :10   1st Qu.: 3.25   N.unique : 5   1st Qu.:23.5   1st Qu.: 8.625  
 N.blank  : 0   Median : 6.50   N.blank  : 0   Median :30.5   Median :11.100  
 Min.nchar:10   Mean   :10.80   Min.nchar: 1   Mean   :29.9   Mean   :12.770  
 Max.nchar:18   3rd Qu.:19.25   Max.nchar: 2   3rd Qu.:34.0   3rd Qu.:15.625  
                Max.   :30.00                  Max.   :40.0   Max.   :26.400  
 is_allstar     
 Mode :logical  
 FALSE:6        
 TRUE :4        
                
                
                

Part 3: Accessing Columns with $

The $ operator lets you pull out a single column as a vector:

# Get the player names
warriors$player
 [1] "Stephen Curry"      "Klay Thompson"      "Draymond Green"    
 [4] "Andrew Wiggins"     "Kevon Looney"       "Jonathan Kuminga"  
 [7] "Moses Moody"        "Brandin Podziemski" "Gary Payton II"    
[10] "Chris Paul"        
# Get their points per game
warriors$ppg
 [1] 26.4 17.9  8.5 14.2  6.3 16.1  9.4 12.8  7.1  9.0
# Calculate team statistics
cat("Team average PPG:", round(mean(warriors$ppg), 1), "\n")
Team average PPG: 12.8 
cat("Highest scorer:  ", warriors$player[which.max(warriors$ppg)], "\n")
Highest scorer:   Stephen Curry 
cat("Average age:     ", round(mean(warriors$age), 1), "years\n")
Average age:      29.9 years
Notewhich.max() and which.min()

These functions return the position of the largest or smallest value in a vector — very useful for finding who scored the most, which game had the fewest points, etc.

which.max(warriors$ppg)    # Position 1 = Curry (highest PPG)
[1] 1
which.min(warriors$age)    # Position 8 = Podziemski (youngest)
[1] 8

Part 4: Accessing Rows and Cells with [ , ]

For a data frame, you can select specific rows and/or columns using [row, column]:

# First row (first player)
warriors[1, ]
         player jersey_num position age  ppg is_allstar
1 Stephen Curry         30       PG  37 26.4       TRUE
# First column (all players' names)
warriors[, 1]
 [1] "Stephen Curry"      "Klay Thompson"      "Draymond Green"    
 [4] "Andrew Wiggins"     "Kevon Looney"       "Jonathan Kuminga"  
 [7] "Moses Moody"        "Brandin Podziemski" "Gary Payton II"    
[10] "Chris Paul"        
# Row 1, Column 5 (Curry's PPG)
warriors[1, 5]
[1] 26.4
# Multiple rows
warriors[1:3, ]
          player jersey_num position age  ppg is_allstar
1  Stephen Curry         30       PG  37 26.4       TRUE
2  Klay Thompson         11       SG  34 17.9       TRUE
3 Draymond Green         23       PF  34  8.5       TRUE
# Specific columns by name
warriors[, c("player", "ppg")]
               player  ppg
1       Stephen Curry 26.4
2       Klay Thompson 17.9
3      Draymond Green  8.5
4      Andrew Wiggins 14.2
5        Kevon Looney  6.3
6    Jonathan Kuminga 16.1
7         Moses Moody  9.4
8  Brandin Podziemski 12.8
9      Gary Payton II  7.1
10         Chris Paul  9.0
TipThe Golden Rule of [row, column]

In R, brackets for 2D objects always go [row, column].

  • warriors[2, ] → second row, all columns
  • warriors[, 3] → all rows, third column
  • warriors[2, 3] → specific cell: row 2, column 3

Leave a position blank to mean “all of them.”


Part 5: Adding Columns

You can add new columns to an existing data frame using $:

# Add a fantasy basketball value column
# (PPG * 1.5 + bonus 5 if All-Star)
warriors$fantasy_value <- warriors$ppg * 1.5 + ifelse(warriors$is_allstar, 5, 0)
warriors[, c("player", "ppg", "is_allstar", "fantasy_value")]
               player  ppg is_allstar fantasy_value
1       Stephen Curry 26.4       TRUE         44.60
2       Klay Thompson 17.9       TRUE         31.85
3      Draymond Green  8.5       TRUE         17.75
4      Andrew Wiggins 14.2      FALSE         21.30
5        Kevon Looney  6.3      FALSE          9.45
6    Jonathan Kuminga 16.1      FALSE         24.15
7         Moses Moody  9.4      FALSE         14.10
8  Brandin Podziemski 12.8      FALSE         19.20
9      Gary Payton II  7.1      FALSE         10.65
10         Chris Paul  9.0       TRUE         18.50
# Add a "veteran" column (TRUE if age >= 30)
warriors$veteran <- warriors$age >= 30
warriors[, c("player", "age", "veteran")]
               player age veteran
1       Stephen Curry  37    TRUE
2       Klay Thompson  34    TRUE
3      Draymond Green  34    TRUE
4      Andrew Wiggins  29   FALSE
5        Kevon Looney  28   FALSE
6    Jonathan Kuminga  22   FALSE
7         Moses Moody  22   FALSE
8  Brandin Podziemski  21   FALSE
9      Gary Payton II  32    TRUE
10         Chris Paul  40    TRUE

Part 6: Matrices

A matrix is like a data frame but with one important restriction: all values must be the same type (usually all numbers).

Matrices are useful for mathematical operations in biology — like comparing gene expression across samples, or a season-by-season stats table.

# Create a matrix of playoff statistics for 4 Warriors players
# Rows = Players, Columns = Stats (Points, Assists, Rebounds per game)
playoff_stats <- matrix(
  c(29.5, 6.2, 5.1,    # Curry
    18.3, 3.1, 4.8,    # Klay
     9.1, 7.4, 8.9,    # Draymond
    15.7, 4.0, 5.2),   # Wiggins
  nrow    = 4,
  ncol    = 3,
  byrow   = TRUE        # Fill row by row
)

# Add row and column names
rownames(playoff_stats) <- c("Curry", "Klay", "Draymond", "Wiggins")
colnames(playoff_stats) <- c("PPG", "APG", "RPG")

playoff_stats
          PPG APG RPG
Curry    29.5 6.2 5.1
Klay     18.3 3.1 4.8
Draymond  9.1 7.4 8.9
Wiggins  15.7 4.0 5.2
# Matrix math: calculate team totals for each stat
colSums(playoff_stats)
 PPG  APG  RPG 
72.6 20.7 24.0 
# Row means (average stat per player across all categories)
rowMeans(playoff_stats)
    Curry      Klay  Draymond   Wiggins 
13.600000  8.733333  8.466667  8.300000 
# Access like a data frame: [row, column]
playoff_stats["Curry", "PPG"]   # Curry's playoff PPG
[1] 29.5
playoff_stats[, "RPG"]          # All players' rebounds
   Curry     Klay Draymond  Wiggins 
     5.1      4.8      8.9      5.2 
NoteData Frame vs Matrix — When to Use Which?
Feature Data Frame Matrix
Column types Can be mixed (text + numbers) All must be the same type
Best for Real datasets with names, categories, numbers Pure numerical calculations
Access columns $column_name [row, col]
Math functions sum(), mean() per column colSums(), rowMeans() etc.

In biology: Most of your data will be data frames. You’ll use matrices mainly for statistics and gene expression analysis.


Part 7: A Quick Visualization

# Bar chart of Warriors players' PPG
barplot(
  warriors$ppg,
  names.arg = warriors$player,
  col       = "#1D428A",
  las       = 2,           # Rotate x-axis labels vertically
  cex.names = 0.7,         # Smaller font for labels
  ylab      = "Points Per Game",
  main      = "Golden State Warriors\n2025–26 Points Per Game",
  ylim      = c(0, 35)
)
abline(h = mean(warriors$ppg), col = "#FFC72C", lty = 2, lwd = 2)


Part 8: Importing and Exporting CSV Files — Amino Acid Properties

Almost all real-world biological data arrives as a CSV file (Comma-Separated Values) — a plain text file where each row is one observation and columns are separated by commas. This is the universal format for sharing data between spreadsheets, databases, and R.

In this section we will work with a real biochemistry dataset compiled from standard reference values for the 20 canonical amino acids. The physicochemical properties we will use — molecular weight, isoelectric point, and hydrophobicity — are foundational to understanding protein structure and function and appear in virtually every biochemistry and structural biology paper.

NoteAbout This Dataset

The file amino_acid_properties.csv contains properties for all 20 standard amino acids. Key columns include:

Column Description
amino_acid Full name
abbrev_3 Three-letter code (e.g., Ala)
abbrev_1 Single-letter code (e.g., A)
classification Chemical class (nonpolar, polar, charged, aromatic)
molecular_weight_gmol Molecular weight in g/mol
isoelectric_point pH at which net charge = 0 (pI)
hydrophobicity_kd Kyte-Doolittle hydrophobicity index
num_codons Number of codons encoding this amino acid
essential Whether it is an essential amino acid (TRUE/FALSE)

Hydrophobicity values are from: Kyte J & Doolittle RF (1982) A simple method for displaying the hydropathic character of a protein. J Mol Biol 157:105–132. Isoelectric points and molecular weights are from Lehninger’s Principles of Biochemistry (8th ed.).

Step 1: Upload the CSV to Posit Cloud

Your instructor will provide the file amino_acid_properties.csv via Canvas. Download it to your computer, then upload it to your Posit Cloud project:

ImportantUploading Files to Posit Cloud
  1. In your Posit Cloud project, look at the Files panel (bottom right)
  2. Click the Upload button (upward arrow icon)
  3. Click Choose File, select amino_acid_properties.csv from your computer
  4. Click OK

The file will appear in your project’s file list and R can now read it.

Step 2: Read the CSV into R

# Read the amino acid properties CSV into a data frame
aa <- read.csv("amino_acid_properties.csv")
NoteCommon read.csv() Arguments
Argument What it does Default
file File name or path (required)
header Is the first row column names? TRUE
na.strings Text to treat as missing "NA"
stringsAsFactors Convert text columns to factors? FALSE

For most clean CSV files, read.csv("filename.csv") with no extra arguments works perfectly.

TipReading a CSV Directly from a URL

If your instructor shares a data file via a web link, you can skip the upload step entirely:

url <- "https://example.com/data/amino_acid_properties.csv"
aa  <- read.csv(url)

This downloads and reads the file in one step. We will use this approach regularly in later labs.

Step 3: Inspect the Imported Data

After importing any dataset, always run these three checks before doing anything else:

# How many rows and columns?
dim(aa)
[1] 20  9
# What are the column names and data types?
str(aa)
'data.frame':   20 obs. of  9 variables:
 $ amino_acid           : chr  "Alanine" "Arginine" "Asparagine" "Aspartate" ...
 $ abbrev_3             : chr  "Ala" "Arg" "Asn" "Asp" ...
 $ abbrev_1             : chr  "A" "R" "N" "D" ...
 $ classification       : chr  "nonpolar_aliphatic" "positively_charged" "polar_uncharged" "negatively_charged" ...
 $ molecular_weight_gmol: num  89.1 174.2 132.1 133.1 121.2 ...
 $ isoelectric_point    : num  6.01 10.76 5.41 2.77 5.07 ...
 $ hydrophobicity_kd    : num  1.8 -4.5 -3.5 -3.5 2.5 -3.5 -3.5 -0.4 -3.2 4.5 ...
 $ num_codons           : int  4 6 2 2 2 2 2 4 2 3 ...
 $ essential            : logi  FALSE TRUE FALSE FALSE FALSE FALSE ...
# What do the first few rows look like?
head(aa)
  amino_acid abbrev_3 abbrev_1     classification molecular_weight_gmol
1    Alanine      Ala        A nonpolar_aliphatic                 89.09
2   Arginine      Arg        R positively_charged                174.20
3 Asparagine      Asn        N    polar_uncharged                132.12
4  Aspartate      Asp        D negatively_charged                133.10
5   Cysteine      Cys        C    polar_uncharged                121.16
6  Glutamate      Glu        E negatively_charged                147.13
  isoelectric_point hydrophobicity_kd num_codons essential
1              6.01               1.8          4     FALSE
2             10.76              -4.5          6      TRUE
3              5.41              -3.5          2     FALSE
4              2.77              -3.5          2     FALSE
5              5.07               2.5          2     FALSE
6              3.22              -3.5          2     FALSE
WarningThree Checks After Every Import
  1. dim() — Do you have the right number of rows and columns? (Should be 20 rows, 9 columns)
  2. str() — Are the column types correct? Molecular weight should be num, not chr
  3. head() — Does the data look right, or did the header row accidentally become row 1?

These checks take 10 seconds and will save you from a lot of downstream confusion.

Step 4: Explore the Biochemistry

Now that the data is loaded, let’s use what we learned earlier in this lab to explore it:

# Quick statistical summary of all numeric columns
summary(aa)
     amino_acid      abbrev_3       abbrev_1    classification
 Length   :20   Length   :20   Length   :20   Length   :20    
 N.unique :20   N.unique :20   N.unique :20   N.unique : 5    
 N.blank  : 0   N.blank  : 0   N.blank  : 0   N.blank  : 0    
 Min.nchar: 6   Min.nchar: 3   Min.nchar: 1   Min.nchar: 8    
 Max.nchar:13   Max.nchar: 3   Max.nchar: 1   Max.nchar:18    
                                                              
 molecular_weight_gmol isoelectric_point hydrophobicity_kd   num_codons  
 Min.   : 75.03        Min.   : 2.770    Min.   :-4.50     Min.   :1.00  
 1st Qu.:118.63        1st Qu.: 5.570    1st Qu.:-3.50     1st Qu.:2.00  
 Median :132.61        Median : 5.815    Median :-0.85     Median :2.00  
 Mean   :136.90        Mean   : 6.024    Mean   :-0.49     Mean   :3.05  
 3rd Qu.:150.70        3rd Qu.: 5.987    3rd Qu.: 2.05     3rd Qu.:4.00  
 Max.   :204.23        Max.   :10.760    Max.   : 4.50     Max.   :6.00  
 essential      
 Mode :logical  
 FALSE:10       
 TRUE :10       
                
                
                
# Which amino acid has the highest molecular weight?
aa$amino_acid[which.max(aa$molecular_weight_gmol)]
[1] "Tryptophan"
# Which is most hydrophobic (highest Kyte-Doolittle score)?
aa$amino_acid[which.max(aa$hydrophobicity_kd)]
[1] "Isoleucine"
# Which is most hydrophilic (lowest score)?
aa$amino_acid[which.min(aa$hydrophobicity_kd)]
[1] "Arginine"
# How many essential vs non-essential amino acids?
table(aa$essential)

FALSE  TRUE 
   10    10 
# Average molecular weight by chemical classification
tapply(aa$molecular_weight_gmol, aa$classification, mean)
          aromatic negatively_charged nonpolar_aliphatic    polar_uncharged 
          183.5367           140.1150           115.4214           124.7280 
positively_charged 
          158.5167 
# Visualize: molecular weight vs isoelectric point
plot(aa$molecular_weight_gmol, aa$isoelectric_point,
     xlab = "Molecular Weight (g/mol)",
     ylab = "Isoelectric Point (pI)",
     main = "Amino Acid Properties\nMolecular Weight vs. Isoelectric Point",
     pch  = 16,
     col  = ifelse(aa$essential, "#C8102E", "#1D428A"))
text(aa$molecular_weight_gmol, aa$isoelectric_point,
     labels = aa$abbrev_1,
     pos    = 3,
     cex    = 0.7)
legend("topright",
       legend = c("Essential", "Non-essential"),
       col    = c("#C8102E", "#1D428A"),
       pch    = 16)

NoteWhat Is the Isoelectric Point?

The isoelectric point (pI) is the pH at which an amino acid carries no net electrical charge. At pH values below the pI, the molecule is positively charged; above the pI, it is negatively charged.

Notice in the plot that the positively charged amino acids (Arg, Lys, His) cluster at high pI values (above 7), while negatively charged ones (Asp, Glu) have very low pI values. This property is exploited in techniques like gel electrophoresis and ion-exchange chromatography.

Step 5: Write a Filtered Dataset to CSV

Suppose you want to save just the essential amino acids to a separate file to share with a colleague:

# Subset: essential amino acids only
essential_aa <- aa[aa$essential == TRUE, ]

cat("Number of essential amino acids:", nrow(essential_aa), "\n")
Number of essential amino acids: 10 
# Write to a new CSV
write.csv(essential_aa,
          file      = "essential_amino_acids.csv",
          row.names = FALSE)   # ALWAYS use row.names = FALSE

cat("File saved. Check your Files panel.\n")
File saved. Check your Files panel.
ImportantAlways Use row.names = FALSE

By default, write.csv() prepends a column of row numbers (1, 2, 3…) to your file. This is almost never useful and creates problems when you re-import the file. Always include row.names = FALSE.

# Read it back to confirm it saved correctly
check <- read.csv("essential_amino_acids.csv")
dim(check)
[1] 10  9
head(check)
  amino_acid abbrev_3 abbrev_1     classification molecular_weight_gmol
1   Arginine      Arg        R positively_charged                174.20
2  Histidine      His        H positively_charged                155.16
3 Isoleucine      Ile        I nonpolar_aliphatic                131.17
4    Leucine      Leu        L nonpolar_aliphatic                131.17
5     Lysine      Lys        K positively_charged                146.19
6 Methionine      Met        M nonpolar_aliphatic                149.21
  isoelectric_point hydrophobicity_kd num_codons essential
1             10.76              -4.5          6      TRUE
2              7.59              -3.2          2      TRUE
3              5.98               4.5          3      TRUE
4              5.98               3.8          6      TRUE
5              9.74              -3.9          2      TRUE
6              5.74               1.9          1      TRUE

Step 6: Download the CSV from Posit Cloud

After saving a file in R, retrieve it from Posit Cloud to your own computer:

NoteDownloading Files from Posit Cloud
  1. In the Files panel (bottom right), check the box next to essential_amino_acids.csv
  2. Click More (the gear icon at the top of the Files panel)
  3. Select Export
  4. Click Download

The file will download to your Downloads folder and can be opened in Excel or shared with collaborators.

TipThe Full Data Science File Workflow

In nearly every lab going forward, the workflow will be:

  1. Importread.csv("file.csv") to load your data
  2. Explorestr(), head(), summary() to understand it
  3. Analyze — filter, calculate, summarize
  4. Exportwrite.csv(df, "results.csv", row.names = FALSE) to save your results
  5. Download — use the Files panel to get the CSV onto your computer

Mastering this cycle means you can take data from any published source, process it in R, and share your results in a format any software can open.


3-Minute Knowledge Check

Close your notes. Answer these on your own — you have 3 minutes. We’ll go through the answers together.

CautionKnowledge Check Questions

1. You have a data frame called roster. Which function gives you a quick overview of column names, types, and sample values?

a) `summary(roster)`  &nbsp;&nbsp; b) `str(roster)`  &nbsp;&nbsp; c) `head(roster)`  &nbsp;&nbsp; d) `dim(roster)`

2. How do you access the ppg column of a data frame called warriors?

3. In df[3, ], what does leaving the column position blank mean?

a) Select no columns  &nbsp;&nbsp; b) Select all columns  &nbsp;&nbsp; c) Select the third column  &nbsp;&nbsp; d) It causes an error

4. What is the key difference between a data frame and a matrix in R?

5. True or False: A data frame can have a column of text AND a column of numbers.

1. b) str(roster)str() shows the structure: number of rows/columns, column names, data types, and sample values. This is the single most useful first-look function in R.

2. warriors$ppg — The $ operator accesses a named column as a vector. You can also use warriors[, "ppg"] or warriors[, 5] if PPG is the 5th column.

3. b) Select all columns — In [row, column] notation, leaving a position blank means “give me everything.” So df[3, ] means row 3, all columns.

4. A data frame can hold mixed types (numbers, text, logical values in different columns). A matrix can only hold one type — usually all numbers. Most real data belongs in a data frame.

5. True — This is one of the most important features of data frames. You can have a player column (character) and a ppg column (numeric) in the same data frame.


Lab 5 Checklist

Before you leave, make sure you can:

TipBonus Challenge

Build a data frame for any sports team of your choice with at least 5 players and 4 variables (at least one should be text, one numeric, one logical). Use str() and summary() to explore it. Calculate the team average for at least one numeric variable. Add it to your Quarto document!


Before Next Class (Monday, Week 3)

  • Read Intro2r Ch. 3 §3.2–3.3
  • Monday (Lab 6) we learn subsetting — how to filter and select specific parts of your data
  • Practice: try adding two more columns to the warriors data frame above