Course contents
Part VChapter 28Other kinds of learning
Positive–Unlabeled EHR Labels
On this page
Chapter 27 introduced positive–unlabeled (PU) learning in a short overview. This chapter is the worked deep dive: a full simulated example where the hidden true labels are known, so you can see exactly what goes wrong when unlabeled rows are treated as negatives.
1. The outcome is only partially observed
Suppose a clinical export contains documented positives, but everyone without a diagnosis code is simply marked not recorded. That second group is not a confirmed negative group; it may include people who have the disease but were never tested or whose records are incomplete.
A binary classifier trained with positive (P) versus unlabeled (U) will ordinarily learn the labeling process as well as the disease process unless careful assumptions and methods are used.
Example: ten invented patients. Four truly have the disease, but only two of those four have a diagnosis code. The export marks those two “P” and the other eight “U”.
truth <- c("Yes", "Yes", "Yes", "Yes", "No", "No", "No", "No", "No", "No")
documented <- c("P", "P", "U", "U", "U", "U", "U", "U", "U", "U")
table(observed = documented, hidden_true = truth)
Output
hidden_true
observed No Yes
P 0 2
U 6 2The U row contains 2 people with the disease and 6 without, and the data alone cannot tell them apart. Calling every U “negative” would be wrong for 2 of 8.
2. Simulate incomplete labeling without using real records
Our fictional dataset has a hidden, known ground truth, so we can simulate what happens when only some true positives become “documented positives.” This does not reproduce real clinic selection bias.
library(dplyr)
library(readr)
ehr <- read_csv("data/synthetic_ehr.csv", show_col_types = FALSE)
fake_id_number <- as.integer(sub("S", "", ehr$patient_id))
toy_pu <- ehr |>
mutate(
observed_status = if_else(
diabetes == "Yes" & fake_id_number %% 3 == 0,
"P", "U"
)
)
table(observed = toy_pu$observed_status, hidden_true_label = toy_pu$diabetes)
Output
hidden_true_label
observed No Yes
P 0 16
U 546 38Some rows in U have hidden synthetic “Yes” labels. In real EHR data, the hidden columns would generally not be available. We include them here only to reveal the problem.
3. Why naive negative sampling can mislead
A simple experiment might sample twice as many unlabeled cases as positives and train P versus sampled U with a Random Forest. But its predicted probability is not automatically the probability of disease, and a “specificity” evaluated by calling U negative is not clinical specificity. It might instead distinguish who received documentation/testing from who did not.
The choice of unlabeled sample size changes class balance and often affects scores, and without verified negatives the clinical false-positive rate remains unknown.
Example: suppose a classifier were perfect at finding the people who truly have diabetes in our toy data. Scored against the P/U labels, its “specificity” would still fall below 1, because the hidden positives in U count as false alarms.
flagged <- toy_pu$diabetes == "Yes" # a perfect disease detector
is_U <- toy_pu$observed_status == "U"
mean(!flagged[is_U]) # apparent "specificity" against U
Output
[1] 0.9349315A perfect detector scores 0.93 here, below 1.00, only because some people in U are truly positive. The number measures the labeling process, not the quality of the detector.
4. What real PU learning needs
Possible methods include reliable-negative identification, nonnegative PU risk estimation, bagging, selection-model approaches, and sensitivity analysis under assumptions about positive labeling. They are not interchangeable.
At minimum, specify:
- Why an example becomes documented positive rather than unlabeled.
- Whether labeling is independent of predictors among true positives (often unrealistic).
- Whether positive class prevalence is known, estimated, or unknown.
- How sampling and class priors affect interpretation.
- Whether any independently adjudicated outcomes are available for evaluation.
- What subgroup or site-specific testing patterns could distort results.
Example: one useful assumption is the label frequency, the share of true positives that get documented. In our toy data we can compute it because the hidden truth is known; in real data it has to be argued from outside information. If it is known, the number of true positives can be estimated from the documented ones.
n_P <- sum(toy_pu$observed_status == "P")
label_freq <- n_P / sum(toy_pu$diabetes == "Yes") # known only in a simulation
c(documented = n_P, label_freq = round(label_freq, 2),
estimated_true_positives = round(n_P / label_freq),
actual_true_positives = sum(toy_pu$diabetes == "Yes"))
Output
documented label_freq estimated_true_positives
16.0 0.3 54.0
actual_true_positives
54.0 Dividing the documented count by the label frequency recovers the actual number of positives. With a wrong label frequency the estimate would be wrong by the same factor, which is why this assumption must be stated.
If you can assemble a properly confirmed independent positive/negative evaluation set, evaluate on that set, not on arbitrary P/U pseudo-labels. AUC, calibration, sensitivity, and specificity should be interpreted relative to the verified reference label, with uncertainty.
5. Research pipeline versus clinical tool
An exploratory PU ranking may help prioritize chart review under an approved study, but it is not evidence that unlabeled people are negative or that the model can diagnose disease. Determine whether the proposed application is research, quality improvement, or clinical decision support and obtain the relevant review.
Worked scenario: a model ranks 600 records and a study team has budget to review 30 charts. The team reads the top 30, and 12 turn out to have the disease on review. The result “12 of 30 reviewed were true positives” is a finding about those reviewed charts, under the study’s approval. It does not mean the other 570 are negative, and it does not make the ranking a diagnostic test. Using the same ranking to tell patients they have or lack the disease would be a clinical use and needs a different level of review.
Try it
- Use
table()to count how many hidden Yes rows are in U.
Show solution to question 1
Answer: 38 hidden Yes rows are in U.
Step by step
- Cross-tabulate the observed status with the hidden label.
tab <- table(observed = toy_pu$observed_status, hidden_true_label = toy_pu$diabetes)
tabOutput
hidden_true_label
observed No Yes
P 0 16
U 546 38tab["U", "Yes"]Output
[1] 382. Read the “U” row and the “Yes” column. The U row has 546 hidden No and 38 hidden Yes, so 38 patients with the disease are in the unlabeled group. The P row has all 16 documented positives, so only 16 of the 54 true positives (about 30%) were documented and 38 sit in U.
- Re-run the exercise with a different documented-positive rule (e.g.
fake_id_number %% 4 == 0). How does the P/U ratio change?
Show solution to question 2
Answer: The P group shrinks from 16 to 11 rows and U grows from 584 to 589, so the documented share falls from about 2.7% to 1.8% of the 600 rows.
Step by step
- Change the rule to every fourth id and rebuild the status column.
toy_pu4 <- ehr |>
mutate(observed_status = if_else(diabetes == "Yes" & fake_id_number %% 4 == 0, "P", "U"))
table(observed = toy_pu4$observed_status, hidden_true_label = toy_pu4$diabetes)Output
hidden_true_label
observed No Yes
P 0 11
U 546 43round(table(toy_pu4$observed_status) / nrow(toy_pu4), 3)Output
P U
0.018 0.982 2. Compare with the %% 3 rule: P was 16 and U 584 (16/600 = 2.7%). With %% 4, P is 11 and U is 589 (11/600 = 1.8%). A rarer documentation rule leaves more true positives hidden in U (43 instead of 38), but the hidden truth itself did not change.
- Explain why the meaning of ROC AUC changes when U is incorrectly labeled negative.
Show solution to question 3
Answer: AUC then measures how well the score separates “documented” from “not documented”, not “sick” from “healthy”. Hidden positives in U are counted as errors, so even a perfect disease detector gets an AUC below 1.
Step by step
- Take a perfect disease score (1 for truly diabetic, 0 otherwise) and compute its AUC against the P/U labels.
perfect <- as.numeric(toy_pu$diabetes == "Yes")
truth_pu <- factor(toy_pu$observed_status, levels = c("P", "U"))
yardstick::roc_auc_vec(truth_pu, perfect)Output
[1] 0.96746582. The AUC is about 0.97, not 1, even though the score is a perfect disease detector. The 38 true positives in U are counted as wrong, so the number reflects the labeling and not the detector. 3. In real data we cannot compute the perfect score, so the true AUC is unknown. The number you can compute is only about the labels you have.
- List three assumptions or external data sources needed before treating a PU output as a disease score.
Show solution to question 4
Answer: (1) A stated reason why a true positive becomes documented, for example that testing was ordered by symptoms. (2) The prevalence or label frequency, known or estimated from an outside source. (3) An independently confirmed positive and negative set for evaluation, such as chart review or a registry.
Step by step
- Ask what the model has learned: the disease, or the process that creates documented positives. That needs assumption 1.
- Ask how to turn a P-versus-U score into a probability of disease. That needs the label frequency or prevalence (assumption 2), as in the section 4 example where dividing by the label frequency recovered the true count.
- Ask how to measure performance. Only verified labels (source 3) give honest sensitivity, specificity and calibration.
- Other acceptable answers: site or subgroup testing patterns, or a sensitivity analysis over a range of plausible label frequencies.
- Explain the difference between “positive not documented” and “confirmed negative.”
Show solution to question 5
Answer: “Positive not documented” means the disease may be present but no code was recorded. “Confirmed negative” means someone checked and ruled it out. U records are unlabeled: some are undocumented positives, none is a confirmed negative, and the export cannot tell which.
Step by step
- Count what the U group really contains in the toy data.
u_rows <- toy_pu |> filter(observed_status == "U")
table(hidden_true_label = u_rows$diabetes)Output
hidden_true_label
No Yes
546 38 2. 38 of the 584 U rows (about 6.5%) have the hidden label Yes: they are positives that were not documented. The other 546 are No in the simulation, but the export itself shows no difference between the two kinds. 3. A confirmed negative would need a test or review that rules the disease out. In real EHR data nothing in U tells you which kind each person is.
Checkpoint
You can distinguish positive, confirmed negative, and unlabeled records and state why naive P-versus-U classification is not a validated disease classifier.