0%
DQDigital Qasas Courses/Medical Statistics with R and Python
Sign inAll courses
  • Why R and Python, and running them in your browser
  • Data frames, import, and tidy medical data
  • Tables and summaries
  • Variables, types, and the question behind the data
  • Displaying data: frequency distributions, histograms, and shape
  • Means, standard deviations and standard errors
  • The normal distribution
  • Confidence interval for a mean
  • Using P-values and confidence intervals
  • Comparison of two means
  • Analysis of variance
  • Linear regression and correlation
  • Multiple regression and diagnostics
  • Transformations
  • Risk, odds, and how to compare them
  • Proportions and the binomial distribution
  • Two proportions: risk ratio, odds ratio, risk difference, and confidence intervals
  • Chi-squared: 2x2 tables, larger tables, trend, and exact tests
  • Confounding and stratification
  • Logistic regression
  • Matched studies
  • Rates and the Poisson distribution
  • Comparing rates and Poisson regression
  • Standardization: direct, indirect, and the SMR
  • Kaplan-Meier survival curves and the log-rank test
  • Cox proportional-hazards regression
  • Likelihood and the generalized linear model
  • Building models, checking assumptions, and clustered data
  • Systematic reviews and meta-analysis
  • Bayesian statistics
  • Linking analysis to study design
  • Sample size and power
  • Measurement error and its consequences
  • Capstone: from a messy dataset to a reported result
0. Getting started: R and Python for medical data

Data frames, import, and tidy medical data

Last updated 30 June 2026

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?

  • The month is a variable, but it is stored in the column names instead of its own column, so one variable (blood pressure over time) is spread across three columns.
  • Each column holds one type, and all three bp columns hold numbers, so a table like this is already fine.
  • The columns use month abbreviations as names, which a data frame does not allow.
Tidy data keeps one variable per column. Here there are two variables, month and blood pressure, but the layout hides month inside the column names and splits one reading across three columns. The fix is to reshape into columns like 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 -> TRUE

Store 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 alphabetical

Tip

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 skip

Worked 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.67

From 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?

  • The mean of a set that contains an unknown value is itself unknown, so R returns NA until you add na.rm = TRUE to drop the missing entries first.
  • The sbp column was stored as text, so the mean cannot be computed.
  • R found an error in the data frame and refuses to summarise it.
A single missing value propagates: you cannot average numbers when one of them is unknown, so the answer is honestly unknown too. This is a deliberate safeguard, not an error or a type problem. Setting 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 check str() or dtypes before you compute, and clean the column to numeric first.
  • Forgetting na.rm = TRUE in R. One missing value makes mean(), sd(), and sum() all return NA. The data is fine; you just need to tell R to drop the gaps. Pandas skips NaN by 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]. Writing patients[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, and summary()/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 NA rather 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 sbp in one file and SBP in another, every join and filter has to special-case it.
← PreviousWhy R and Python, and running them in your browserNext →Tables and summaries
On this page
  • What a data frame is
  • Building one inline, and reading a CSV
  • Worked example 1: a clinic table from Seremban
  • Tidy data: one row, one column, one value
  • Categorical variables: factors and the category type
  • Missing values: NA in R, NaN in pandas
  • Worked example 2: select, filter, and summarise
  • Common mistakes
  • Tips