Course contents
Part IIChapter 9Supervised learning: the complete workflow
Define the Research Question and Label
On this page
You have finished the Part I foundations (see the Part I milestone at the end of chapter 8). Part II applies those tools to one research question, carried from a clear question through to a tested model.
1. Prediction questions need five parts
A useful project specification says: population, prediction time, input variables, outcome, and use. For example:
Among fictional adults with a completed visit, can age, BMI, systolic pressure, pulse, family history, sex, and site predict a fully observed synthetic diabetes label? The prediction is educational only.
The prediction time matters. Inputs measured after the outcome or after treatment may not have been available at prediction time.
Example: a vague idea is “predict diabetes.” Fill in the five parts one at a time. Population: adults aged 18 and over with a completed visit. Prediction time: the end of the first visit. Inputs: age, BMI, systolic pressure, pulse, sex, family history and site, all measured at that visit. Outcome: the Yes/No diabetes label in the file. Use: educational only. Joined together: “For adults at the end of a completed first visit, predict the Yes/No diabetes label from the measurements taken at that visit.” A lab result entered a week later fails the prediction-time test, so it is left out.
2. Predictors and outcome
- Features / predictors (X): the information the model is allowed to see.
- Target / label / outcome (Y): the answer used to train and evaluate it.
- Prediction (ŷ): the model’s estimated answer for a new row.
Never treat patient_id as a clinical predictor. It is a tracking identifier. Dates and site codes also need careful assessment: a model may memorize calendar or operational patterns rather than patients’ medical states.
library(dplyr)
library(readr)
ehr <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
table(ehr$diabetes)
Output
No Yes
546 54 nrow(ehr)
Output
[1] 600Reading the output: the file has 600 rows (600 fabricated visits), and the diabetes table counts the “No” and “Yes” labels. Those two counts are the outcome we would be trying to predict.
The simulated data have known “Yes” and “No” outcomes by construction. That is a teaching convenience, not the same as a real EHR.
3. The critical EHR label problem
A clinic record may show:
- Confirmed positive: documented diagnosis established with a relevant definition.
- Confirmed negative: independently assessed and confirmed absence under a specified protocol.
- Unlabeled: never tested, unavailable results, missing records, or no diagnosis listed.
“Not diagnosed” cannot automatically be relabeled “negative.” If only positives and unlabeled rows are available, the task is potentially positive–unlabeled (PU) learning, not ordinary supervised binary classification. Ordinary accuracy, specificity, and ROC/AUC calculations (explained in later chapters) need carefully established truth labels.
Example: here are six made-up records, where only some patients were ever tested.
records <- tibble::tibble(
patient = paste0("P", 1:6),
hba1c_tested = c(TRUE, TRUE, TRUE, FALSE, FALSE, FALSE),
diagnosis_listed = c("Yes", "No", "No", NA, NA, "Yes")
)
records
Output
# A tibble: 6 x 3
patient hba1c_tested diagnosis_listed
<chr> <lgl> <chr>
1 P1 TRUE Yes
2 P2 TRUE No
3 P3 TRUE No
4 P4 FALSE <NA>
5 P5 FALSE <NA>
6 P6 FALSE Yes table(records$diagnosis_listed, useNA = "ifany")
Output
No Yes <NA>
2 2 2 Reading the output: P4 and P5 have NA, which means no diagnosis recorded. They were never tested, so we do not know whether they have the condition. Only P2 and P3 are confirmed negatives, because they were tested and the answer was “No”. P6 has a “Yes” diagnosis even though hba1c_tested is FALSE: a diagnosis can be listed without a test in this file, so a listed “Yes” is a positive and only the missing entries are unlabeled. Turning the two NA rows into “No” would invent two negatives.
We discuss this in the governance appendix, and Chapter 28 works through PU learning on a simulated example. The synthetic results do not validate a real PU approach.
4. Selection bias, leakage, and confounding
Imagine a feature called treatment_started. If treatment is initiated because a patient received a diabetes diagnosis, using treatment as a predictor may reveal the answer after the fact. This is target leakage.
Imagine one clinic site tests patients more often than another. A record of diagnosed diabetes could partly reflect access to testing rather than underlying disease. This is ascertainment bias. Demographic or location variables can create fairness and transportability problems.
Imagine a patient appears in both training and testing data. The model could recognize that person’s particular values; this is patient-level leakage. All records from the same person must stay in one split when evaluating patient-level generalization.
Imagine older patients are both more likely to have a family_history flag and more likely to receive a diabetes diagnosis. age would then be related to the predictor and to the outcome, so it could make family_history look predictive even if it adds little beyond age. This is confounding: a third variable that is related to both the predictor and the outcome can create or distort an apparent association.
Example: the file has no treatment column, so we make one up to see what leakage looks like. The column treatment_started is “Yes” exactly when the label is “Yes”, which is how it would behave if treatment began only after diagnosis.
leaky <- ehr |>
mutate(treatment_started = ifelse(diabetes == "Yes", "Yes", "No"))
table(treatment = leaky$treatment_started, diabetes = leaky$diabetes)
Output
diabetes
treatment No Yes
No 546 0
Yes 0 54Reading the output: every “Yes” for treatment sits in the “Yes” diabetes column and every “No” in the “No” column, with nothing off the diagonal. A model given this column would look perfect, but at prediction time (before diagnosis) the column would not exist yet.
5. Create a data dictionary and analysis plan
Before modeling, answer:
- What does each column mean, in what unit, and at what time is it measured?
- Which values mean truly absent versus unrecorded?
- How is the outcome confirmed?
- What is the unit: visit, person, or clinic?
- How will we handle multiple rows per patient?
- What will be held out for final assessment?
- What error has greater costs, and who decides those costs?
Example: a few of these questions can be answered from the file itself.
dictionary <- tibble::tibble(
column = c("age", "bmi", "sbp"),
meaning = c("Age in years at visit", "Body mass index, kg/m^2", "Systolic blood pressure, mmHg"),
n_missing = c(sum(is.na(ehr$age)), sum(is.na(ehr$bmi)), sum(is.na(ehr$sbp)))
)
dictionary
Output
# A tibble: 3 x 3
column meaning n_missing
<chr> <chr> <int>
1 age Age in years at visit 0
2 bmi Body mass index, kg/m^2 35
3 sbp Systolic blood pressure, mmHg 20n_distinct(ehr$patient_id) == nrow(ehr)
Output
[1] TRUEReading the output: age has no missing values, while bmi and sbp have some, so question 2 needs an answer for them (not measured, or not recorded?). The final TRUE says every row has its own patient_id, which answers question 5 for this file: one row per patient, so there are no repeated rows to handle.
In the capstone, our fictional outcome is fully known, and there is one fabricated visit per ID. In a real study those assumptions require evidence.
6. A baseline is mandatory
Suppose 9% of patients have the condition and a model always predicts “No.” Its accuracy would be 91%, even though its sensitivity for “Yes” (the share of true “Yes” cases it catches) is 0%. A good evaluation compares a model with simple baselines and looks beyond accuracy.
Example: the fabricated file has the same shape as that story, and we can check it.
always_no <- rep("No", nrow(ehr))
accuracy <- mean(always_no == ehr$diabetes)
sensitivity <- sum(always_no == "Yes" & ehr$diabetes == "Yes") / sum(ehr$diabetes == "Yes")
c(accuracy = accuracy, sensitivity = sensitivity)
Output
accuracy sensitivity
0.91 0.00 Reading the output: always answering “No” is right 91% of the time (accuracy is 0.91), yet it finds none of the “Yes” cases (sensitivity is 0). Any model we build has to beat 91% accuracy and also has to find some of the positives.
Try it
- Write a one-sentence question with population, inputs, outcome, and prediction time.
Show solution to question 1
Answer: “For adults at the end of a completed first visit, can age, BMI, systolic pressure, pulse, sex, family history and site, all measured at that visit, predict the fabricated Yes/No diabetes label?”
Step by step
- Name the population: adults with a completed visit.
- Name the prediction time: the end of that first visit, so nothing measured later is allowed.
- List the inputs measured by that time: age, BMI, systolic pressure, pulse, sex, family history, site.
- Name the outcome (the
diabeteslabel) and join the parts into one sentence. Check that the sentence says who, when, from what, and to predict what. A sentence without a prediction time is the usual gap.
- Name one predictor you would exclude because it becomes available after diagnosis.
Show solution to question 2
Answer: A “diabetes treatment started” column (or a prescription for diabetes medicine). It is recorded because of the diagnosis, so it leaks the label.
Step by step
- Ask of each candidate column: was it known at the prediction time?
- Treatment starts after diagnosis, so it fails that test. The file has no such column, so we make one that behaves this way and look at it.
leaky <- ehr |>
mutate(treatment_started = ifelse(diabetes == "Yes", "Yes", "No"))
table(treatment = leaky$treatment_started, diabetes = leaky$diabetes)Output
diabetes
treatment No Yes
No 546 0
Yes 0 543. Every “Yes” for treatment lines up with a “Yes” label and every “No” with a “No” label. A model would look perfect on this column and be useless before diagnosis. Other examples: a diabetes-specific lab ordered after diagnosis, or a “referred to diabetes clinic” flag.
- Describe the difference between “No” and “No diagnosis recorded.”
Show solution to question 3
Answer: “No” is a confirmed negative: the patient was assessed and the answer was no. “No diagnosis recorded” is unlabeled: it could be a true negative, or a patient who was never tested.
Step by step
- Build a tiny table with both cases.
check <- tibble::tibble(
patient = c("A", "B", "C"),
tested = c(TRUE, FALSE, FALSE),
diagnosis = c("No", NA, NA)
)
checkOutput
# A tibble: 3 x 3
patient tested diagnosis
<chr> <lgl> <chr>
1 A TRUE No
2 B FALSE <NA>
3 C FALSE <NA> 2. Patient A was tested and is a confirmed “No”. B and C have NA, so the record is silent about them.
3. Recoding the NA rows to “No” would count B and C as healthy without evidence. Some may well have the condition, so the model would be trained on wrong labels and its accuracy would be overstated.
- Explain why two visits from the same person must usually stay in the same training or test group.
Show solution to question 4
Answer: If one person is in both groups, the model can recognize that person’s own values in the test set. The test score then measures memory of known people instead of performance on new patients, so it looks better than it should.
Step by step
- Make a small table with repeated visits. Patient P1 has three rows.
visits <- tibble::tibble(
patient = c("P1", "P1", "P1", "P2", "P3"),
age = c(61, 61, 62, 35, 48),
sbp = c(150, 148, 151, 118, 125)
)
visitsOutput
# A tibble: 5 x 3
patient age sbp
<chr> <dbl> <dbl>
1 P1 61 150
2 P1 61 148
3 P1 62 151
4 P2 35 118
5 P3 48 1252. Suppose a random row split sends two of P1’s visits to training and one to test. The test row (age 62, SBP 151) is almost a copy of the training rows (age 61, SBP 150 and 148).
3. The model can predict that test row well just by having seen P1. That says nothing about a new patient. Keeping all three P1 rows in one group removes the shortcut.
4. In our file each patient_id appears once, so a row split is already a patient split. Check: n_distinct(ehr$patient_id) == nrow(ehr) gives TRUE.
- Write down one foreseeable harm from a false negative.
Show solution to question 5
Answer: A patient who truly has the condition is told “no” and does not get follow-up, so diagnosis and treatment are delayed. With only 54 positives in the 600 fabricated rows, even a handful of missed cases is a large share of the people we care about.
Step by step
- A false negative means: the model says “No”, the truth is “Yes”.
- Count how many positives a careless “always No” rule would miss.
pred <- rep("No", nrow(ehr))
c(positives = sum(ehr$diabetes == "Yes"),
missed_by_always_no = sum(pred == "No" & ehr$diabetes == "Yes"))Output
positives missed_by_always_no
54 54 3. The two numbers are equal because the rule says “No” to everyone, so every true positive is a miss. The rule misses all of the positives it was supposed to find while being right for 91% of rows, so accuracy hides the harm. Naming the harm (delayed follow-up) is how you decide that false negatives should count heavily in the evaluation.
Checkpoint
You can define a prediction task, distinguish features from labels, identify leakage, and recognize why EHR negative labels are often ambiguous.