Data frames, import, and tidy medical data
Last updated
In lesson 0.1 you stored one measurement at a time in a vector: a column of ages, a column of blood-pressure readings. Real clinical data does not arrive one column at a time. It arrives as a table, where each patient has an age, a sex, a diagnosis, and a lab result, all lined up on the same row. The structure that holds that table in R and in Python is the data frame, and almost everything you do for the rest of this course starts by loading data into one.
This lesson builds a small patient table from scratch, looks inside it, pulls out the columns and rows you want, and handles the two things that trip up every beginner: categorical variables and missing values. By the end you can take a sheet of clinic records and get it into a shape your analysis can use.
What a data frame is
A data frame is a rectangular table. Each row is one observation, and each column is one variable. In a clinic dataset a row is usually one patient or one visit, and the columns are the things you recorded: age in years, sex, diagnosis, a systolic blood pressure. Every value within a column shares the same type, so the age column holds numbers and the sex column holds text, yet different columns can hold different types. Unlike a matrix, a data frame can mix numeric and text columns in the same row.
Picture a spreadsheet with rules. The header row names the variables. Every data row has an entry in every column, even when that entry is marked missing. You refer to things by column name rather than by cell coordinate, which is what makes the code below readable.
Key term
A data frame is a table where each row is one observation (such as one patient) and each column is one variable (such as age or diagnosis). Columns can have different types; values within a column share a type. It is the standard container for a dataset in both R (data.frame) and Python (pandas.DataFrame).
Building one inline, and reading a CSV
Most of the time you load a data frame from a file. A comma-separated values file (CSV) is the common export format for clinic systems, and you read it with read.csv() in R or pd.read_csv() in Python. You will use those two functions constantly once you are working on your own machine with your own files.
This browser runs the code without a file system, so we build the table inline instead, with data.frame() in R and pandas.DataFrame() in Python. The logic is the same one a CSV reader applies: name each column, give it a vector of values of equal length, and the function lines them up into rows. Reading a CSV just does this for you from disk. The starter block below shows the read.csv() line you would use for real; the solution builds the same table inline so it runs live here.
Worked example 1: a clinic table from Seremban
Here are six patients from a Klinik Kesihatan in Seremban, with age, sex, recorded diagnosis, and a systolic blood pressure (sbp) in mmHg. One reading was not taken, so it is missing. Build the frame, then inspect it four ways.
Build the patient table inline, then inspect it with head, dim, str, and summary.
patients <- data.frame(
patient_id = 1:6,
age = c(58, 64, 45, 71, 39, 67),
sex = c("F", "M", "F", "M", "F", "M"),
diagnosis = c("Diabetes", "Hypertension", "Diabetes",
"Hypertension", "Asthma", "Diabetes"),
sbp = c(142, 158, NA, 165, 120, 150),
stringsAsFactors = FALSE
)# On your own machine you would read a file instead:
# patients <- read.csv("klinik_seremban.csv")
# We build it inline above so the code runs live in the browser.head(patients, 3) # first 3 rows dim(patients) # rows, columns -> 6 5 str(patients) # type of each column summary(patients) # per-column profile
Build the patient table inline, then inspect it with head, shape, dtypes, and describe.
import pandas as pd
import numpy as np
patients = pd.DataFrame({
"patient_id": [1, 2, 3, 4, 5, 6],
"age": [58, 64, 45, 71, 39, 67],
"sex": ["F", "M", "F", "M", "F", "M"],
"diagnosis": ["Diabetes", "Hypertension", "Diabetes",
"Hypertension", "Asthma", "Diabetes"],
"sbp": [142, 158, np.nan, 165, 120, 150],
})# On your own machine you would read a file instead:
# patients = pd.read_csv("klinik_seremban.csv")
# We build it inline above so the code runs live in the browser.print(patients.head(3)) # first 3 rows print(patients.shape) # (rows, columns) -> (6, 5) print(patients.dtypes) # type of each column print(patients.describe()) # per-column profile
Read the four checks as a routine you run on every new dataset. head() shows the top rows so you can eyeball the table without printing all of it. dim() reports rows by columns (6 by 5 here), and shape does the same in pandas. str() in R and dtypes in pandas give the type of each column, which is how you catch a number that was read in as text. summary() in R and describe() in pandas give a quick per-column profile: range and mean for the numeric columns, counts for the rest.
Watch out
Check column types before you trust any number. If a stray letter or a stored unit like "150 mmHg" sits in the sbp column, the reader treats the whole column as text, and mean() then fails or returns nonsense. A column that should be numeric showing up as chr in str() or object in dtypes is your first warning that the source file needs cleaning.
Tidy data: one row, one column, one value
How you lay out the table decides how hard the analysis is. The tidy-data rule is three short statements: one row per observation, one column per variable, one value per cell. A tidy clinic table gives each patient their own row, keeps age and sbp in separate columns, and never crams two readings into a single cell.
Untidy data breaks at least one of those rules. A column header like sbp_visit1 sbp_visit2 sbp_visit3 packs the visit number into the column names, when visit is a variable that deserves its own column. A cell holding "142/90" stores two values at once. Tidy data is worth the reshaping effort because every function in R and pandas expects it: filtering, grouping, and plotting all assume one variable per column.
A clinic gives you a table where each row is one patient and the columns are bp_jan, bp_feb, bp_mar, each holding that month's blood pressure. Why is this not tidy?
patient_id, month, and bp, giving one row per patient-month. The number of columns is not the problem, and a numeric blood-pressure column is exactly what you want.Categorical variables: factors and the category type
Some columns are not numbers and not free text. They take one of a fixed set of labels: sex is F or M, a blood-pressure category is Normal, Elevated, or High. R calls such a column a factor, and pandas calls it the category dtype. Declaring a column categorical tells the software the full set of allowed labels (the levels) and, when you ask for it, the order they run in.
Order matters for any scale that has a direction. A severity scale runs Mild, Moderate, Severe; a kidney stage runs 1 through 5. If you leave such a column as plain text, the software sorts it alphabetically, which scrambles the clinical order. An ordered factor fixes the sequence so that comparisons and sorts respect it. Below, a blood-pressure category is stored as an ordered factor with the levels in clinical order, not alphabetical order.
Store a blood-pressure category as an ordered factor and confirm the levels run in clinical, not alphabetical, order.
bpcat <- c("Normal", "High", "Elevated", "High", "Normal", "Elevated")bp <- factor(bpcat, levels = c(...), ordered = TRUE)
bp <- factor(bpcat,
levels = c("Normal", "Elevated", "High"),
ordered = TRUE)
levels(bp) # "Normal" "Elevated" "High"
table(bp) # count per level
bp[2] > bp[1] # High > Normal -> TRUEStore a blood-pressure category as an ordered Categorical and confirm the levels run in clinical, not alphabetical, order.
import pandas as pd bpcat = ["Normal", "High", "Elevated", "High", "Normal", "Elevated"]
bp = pd.Categorical(bpcat, categories=[...], ordered=True)
bp = pd.Categorical(bpcat,
categories=["Normal", "Elevated", "High"],
ordered=True)
print(bp.categories) # Index(['Normal', 'Elevated', 'High'])
print(bp.max()) # High -> levels ordered, not alphabeticalTip
Set the levels yourself whenever a category has a natural order. If you rely on the default, both R and pandas order the levels alphabetically, so "High" lands before "Normal" and any sorted table or chart reads in the wrong sequence. Naming the levels once, in clinical order, saves you from silently wrong plots later.
Missing values: NA in R, NaN in pandas
Clinic data has gaps. A blood pressure was never taken, a form field was left blank, a lab result is still pending. R marks an absent value as NA and pandas marks it as NaN. Both mean the same thing: the value is unknown, which is different from zero and different from an empty string.
A missing value spreads through arithmetic. The mean of a column that contains one unknown is itself unknown, so in R mean(sbp) returns NA until you tell it to drop the gaps with na.rm = TRUE. Pandas takes the opposite default and skips NaN automatically in .mean(), while plain NumPy does not. Knowing which tool skips and which propagates saves you from quietly wrong averages.
Show how a missing value propagates through a mean, and how na.rm and is.na handle it.
sbp <- c(142, 158, NA, 165, 120, 150)
mean(sbp) # what does this give? mean(sbp, na.rm = TRUE) # and this?
mean(sbp) # NA: one value is unknown mean(sbp, na.rm = TRUE) # 147: gaps dropped first is.na(sbp) # which entries are missing sum(is.na(sbp)) # how many missing -> 1
Show that pandas skips NaN by default while NumPy propagates it, and count the gaps.
import numpy as np import pandas as pd sbp = pd.Series([142, 158, np.nan, 165, 120, 150])
print(sbp.mean()) # pandas default? print(sbp.isna().sum())
print(sbp.mean()) # 147.0: pandas skips NaN
print(sbp.isna().sum()) # how many missing -> 1
print(np.mean([142, 158, np.nan,
165, 120, 150])) # nan: NumPy does NOT skipWorked example 2: select, filter, and summarise
The everyday job with a data frame is to pull out the part you care about and describe it. Two operations cover most of it. Selecting picks columns; filtering picks rows that meet a condition. Suppose you want the older patients at the Seremban clinic, those over 60, and their average systolic pressure. Select the columns you need, filter the rows on age, then summarise the sbp column, remembering the missing value.
From the patient table, select two columns, filter to patients over 60, and find their mean sbp.
patients <- data.frame(
patient_id = 1:6,
age = c(58, 64, 45, 71, 39, 67),
sex = c("F", "M", "F", "M", "F", "M"),
diagnosis = c("Diabetes", "Hypertension", "Diabetes",
"Hypertension", "Asthma", "Diabetes"),
sbp = c(142, 158, NA, 165, 120, 150),
stringsAsFactors = FALSE
)older <- patients[patients$age > 60, ] # rows where age > 60 mean(older$sbp, ...) # remember the NA
patients[, c("age", "sbp")] # select two columns
older <- patients[patients$age > 60, ] # filter rows
older
mean(older$sbp, na.rm = TRUE) # 157.67From the patient table, select two columns, filter to patients over 60, and find their mean sbp.
import pandas as pd
import numpy as np
patients = pd.DataFrame({
"patient_id": [1, 2, 3, 4, 5, 6],
"age": [58, 64, 45, 71, 39, 67],
"sex": ["F", "M", "F", "M", "F", "M"],
"diagnosis": ["Diabetes", "Hypertension", "Diabetes",
"Hypertension", "Asthma", "Diabetes"],
"sbp": [142, 158, np.nan, 165, 120, 150],
})older = patients[patients["age"] > 60] # rows where age > 60 print(older["sbp"].mean())
print(patients[["age", "sbp"]]) # select two columns older = patients[patients["age"] > 60] # filter rows print(older) print(older["sbp"].mean()) # 157.67
Three patients are over 60: the ones aged 64, 71, and 67. Their systolic readings are 158, 165, and 150, so the mean is 157.67 mmHg. Notice the syntax difference in R. The square brackets take two slots, df[rows, columns], so patients[patients$age > 60, ] keeps the comma with an empty column slot to mean "all columns." Pandas filters with a boolean mask inside a single pair of brackets. Both read the same way once you have seen them a few times.
Example
To filter on a category instead of a number, compare to the label. In R, patients[patients$diagnosis == "Diabetes", ] returns the three diabetic patients. In pandas, patients[patients["diagnosis"] == "Diabetes"] does the same. Selecting and filtering are the building blocks behind every table you will produce later in the course.
In R you run mean(patients$sbp) on a column that holds one NA, and it returns NA rather than a number. What is the right reading?
na.rm = TRUE tells R to remove the NA and average the rest. Pandas makes the opposite default choice and skips NaN automatically, which is why sbp.mean() there returns a number without any extra argument.Common mistakes
- Trusting a number column stored as text. A unit left in a cell, like "150 mmHg", turns the whole sbp column into text. Then
mean()fails or gives nonsense. Always checkstr()ordtypesbefore you compute, and clean the column to numeric first. - Forgetting
na.rm = TRUEin R. One missing value makesmean(),sd(), andsum()all returnNA. The data is fine; you just need to tell R to drop the gaps. Pandas skipsNaNby default, so the same code behaves differently across the two languages. - Dropping the comma in R's row filter. Rows and columns sit in two slots,
df[rows, columns]. Writingpatients[patients$age > 60]without the comma asks for columns, not rows, and returns the wrong thing. Keep the comma:patients[patients$age > 60, ]. - Leaving an ordered scale as plain text. Severity or stage stored as text sorts alphabetically, so "High" comes before "Normal" and charts read backwards. Declare it an ordered factor (R) or ordered category (pandas) with the levels in clinical order.
Tips
- Run the same four checks on every new dataset:
head(),dim()/shape,str()/dtypes, andsummary()/describe(). Thirty seconds here catches most import problems. - Build a tiny data frame inline, like the six-row table above, whenever you want to test a piece of code. It runs instantly and you can see the expected answer by hand.
- Decide what counts as missing before you import, so blanks, "NA", and "9999" all map to a real
NArather than slipping through as text or as a fake value. - Name factor levels in clinical order the moment you create the column. Fixing the order later, after you have built tables and plots, means redoing all of them.
- Keep the same column names across your whole project. If sbp is
sbpin one file andSBPin another, every join and filter has to special-case it.