This wrap-up lesson simulates a realistic outpatient cardiology mini-audit: you receive a small dataset of clinic patients and you must produce a clean, reproducible report of risk factors.
You will build the solution in tiny, explicit steps, so every command has a clear meaning (not just “it works”).
What you will build
A small script that:
- Loads a mini dataset (embedded in the code, no external files needed)
- Validates + cleans inconsistent values (e.g.,
"yes","Y","1","true") - Computes prevalence of major risk factors
- Stratifies results by sex and age group
- Outputs a clean text report you can paste into a note or email
Where this connects to previous lessons
Use these as internal links on your site (adjust URLs to match your structure):
- Lesson 2 (VS Code setup, running scripts)
- Lesson 3 (variables + types)
- Lesson 4 (strings, numbers, basic ops)
- Lesson 5 (control flow)
- Lesson 6 (functions)
- Lesson 7 (collections)
- Lesson 8 (modules, packages, files)
- Lesson 9 (errors)
- Lesson 10 (object oriented)
- Lesson 11 (intermediate python)
The dataset (outpatient cardiology)
Imagine these are ambulatory patients evaluated for cardiovascular prevention.
Variables
id: patient idage: yearssex:"M"or"F"(with some messy entries)- Risk factors:
smoker,hypertension,diabetes,dyslipidemia(messy yes/no) ldl_mg_dl: LDL cholesterol (some missing)sbp_mmHg: systolic blood pressure (some missing)
We embed the dataset as a list of dictionaries (Lesson 7).
PATIENTS = [
{"id": "P001", "age": 54, "sex": "M", "smoker": "yes", "hypertension": "Y", "diabetes": "no", "dyslipidemia": "1", "ldl_mg_dl": 162, "sbp_mmHg": 148},
{"id": "P002", "age": 67, "sex": "F", "smoker": "no", "hypertension": "no", "diabetes": "No", "dyslipidemia": "yes", "ldl_mg_dl": 135, "sbp_mmHg": 132},
{"id": "P003", "age": 61, "sex": "female", "smoker": "0", "hypertension": "1", "diabetes": "0", "dyslipidemia": "0", "ldl_mg_dl": 118, "sbp_mmHg": 156},
{"id": "P004", "age": 45, "sex": "M", "smoker": "TRUE", "hypertension": "false", "diabetes": "false", "dyslipidemia": "true", "ldl_mg_dl": None, "sbp_mmHg": 125},
{"id": "P005", "age": 73, "sex": "F", "smoker": "n", "hypertension": "y", "diabetes": "y", "dyslipidemia": "y", "ldl_mg_dl": 104, "sbp_mmHg": None},
{"id": "P006", "age": 58, "sex": "M", "smoker": "", "hypertension": "N/A", "diabetes": "no", "dyslipidemia": "no", "ldl_mg_dl": 190, "sbp_mmHg": 140},
{"id": "P007", "age": 39, "sex": "F", "smoker": "No", "hypertension": "0", "diabetes": "0", "dyslipidemia": "0", "ldl_mg_dl": 92, "sbp_mmHg": 118},
{"id": "P008", "age": 52, "sex": "M", "smoker": "Yes", "hypertension": "Yes", "diabetes": "No", "dyslipidemia": "Yes", "ldl_mg_dl": 143, "sbp_mmHg": 151},
{"id": "P009", "age": 64, "sex": "F", "smoker": None, "hypertension": "1", "diabetes": "1", "dyslipidemia": "0", "ldl_mg_dl": 128, "sbp_mmHg": 160},
{"id": "P010", "age": 49, "sex": "M", "smoker": "0", "hypertension": "0", "diabetes": "0", "dyslipidemia": "1", "ldl_mg_dl": 155, "sbp_mmHg": 138},
]
Step 1 — Create a new file and run it in VS Code
Create: wrap_up_cardiology_audit.py
Paste the dataset above at the top, then add:
def main():
print("Loaded patients:",len(PATIENTS))
if __name__ =="__main__":
main()
Meaning
if __name__ == "__main__":ensuresmain()runs only when you execute the file directlylen(PATIENTS)counts elements in a list.
Run it:
- VS Code terminal:
python wrap_up_cardiology_audit.py
Step 2 — Normalize messy yes/no values (the “truth” problem)
In real data, boolean fields arrive as "yes", "Y", "1", "true", etc.
We need one function that converts many possible inputs into:
TrueFalseNone(unknown / missing)
Add this function:
def to_bool(value):
"""
Convert messy yes/no encodings into True/False/None.
Rules:
- True values: '1', 'y', 'yes', 'true', 't'
- False values: '0', 'n', 'no', 'false', 'f'
- Missing/unknown: None, '', 'na', 'n/a', 'null'
"""
if value is None:
return None
text = str(value).strip().lower()
if text in {"", "na", "n/a", "null", "none"}:
return None
if text in {"1", "y", "yes", "true", "t"}:
return True
if text in {"0", "n", "no", "false", "f"}:
return False
# If we reach here, we got a value we did not expect.
# In real projects you might raise an error or log it.
return None
Why this matters (and what each part means)
str(value)ensures even numbers like1become strings, so we can normalize them consistently..strip()removes surrounding spaces..lower()makes"TRUE"equal to"true".- The function returns
Nonewhen the value is ambiguous instead of guessing.
This is a key “intermediate mindset”: prefer explicit unknowns over silent wrong assumptions.
Step 3 — Normalize sex values (another messy field)
We want "M" or "F" only.
def normalize_sex(value):
"""
Normalize sex field to 'M', 'F', or None.
Accepts: 'M', 'F', 'male', 'female', case-insensitive.
"""
if value is None:
return None
text = str(value).strip().lower()
if text in {"m", "male"}:
return "M"
if text in {"f", "female"}:
return "F"
return None
Step 4 — Clean the dataset into a new, “trusted” structure
Instead of mutating the original data, create a cleaned version (safer + debuggable).
RISK_FACTORS = ["smoker", "hypertension", "diabetes", "dyslipidemia"]
def clean_patients(raw_patients):
"""
Return a new list of cleaned patient dicts.
"""
cleaned = []
for p in raw_patients:
new_p = dict(p) # shallow copy (Lesson 7)
new_p["sex"] = normalize_sex(p.get("sex"))
for rf in RISK_FACTORS:
new_p[rf] = to_bool(p.get(rf))
cleaned.append(new_p)
return cleaned
Meaning
for p in raw_patients:iterates through list items.p.get("sex")returnsNoneinstead of crashing if the key is missing.dict(p)copies the dictionary so you keep the raw version intact.
Step 5 — Compute prevalence (the core audit metric)
Prevalence = positives / known values (exclude None).
def prevalence(patients, field):
"""
Compute prevalence of a boolean field among known values.
Returns a tuple: (positives, known, prevalence_float_or_None)
"""
positives = 0
known = 0
for p in patients:
value = p.get(field)
if value is None:
continue # skip unknowns
known += 1
if value is True:
positives += 1
if known == 0:
return positives, known, None
return positives, known, positives / known
Key concepts
continuejumps to the next loop iteration.- We guard against division by zero by checking
known == 0.
Step 6 — Create age groups (simple stratification)
We’ll define:
<4545–5455–64>=65
def age_group(age):
"""
Convert a numeric age into a group label.
"""
if age is None:
return None
if age < 45:
return "<45"
if age <= 54:
return "45-54"
if age <= 64:
return "55-64"
return ">=65"
Step 7 — Build the report (human-friendly output)
We’ll create:
- Overall prevalence
- Prevalence by sex
- Prevalence by age group
- A quick check of missingness for LDL and SBP
def count_missing(patients, field):
missing = 0
for p in patients:
if p.get(field) is None:
missing += 1
return missing
def format_pct(x):
if x is None:
return "NA"
return f"{x*100:.1f}%"
def report_prevalence_section(title, patients_subset):
lines = []
lines.append(title)
lines.append("-" * len(title))
for rf in RISK_FACTORS:
pos, known, prev = prevalence(patients_subset, rf)
lines.append(f"{rf:14s}: {pos}/{known} ({format_pct(prev)})")
return "\n".join(lines)
def stratify(patients, key_func):
"""
Group patients into a dict: {group_label: [patients]}
"""
groups = {}
for p in patients:
key = key_func(p)
if key is None:
continue
groups.setdefault(key, []).append(p)
return groups
def build_report(cleaned):
lines = []
lines.append("CARDIOLOGY OUTPATIENT MINI-AUDIT")
lines.append("=" * 32)
lines.append(f"Total patients: {len(cleaned)}")
lines.append("")
# Overall
lines.append(report_prevalence_section("Overall prevalence", cleaned))
lines.append("")
# Missingness check (simple data quality)
ldl_missing = count_missing(cleaned, "ldl_mg_dl")
sbp_missing = count_missing(cleaned, "sbp_mmHg")
lines.append("Data quality")
lines.append("------------")
lines.append(f"Missing LDL (mg/dL): {ldl_missing}/{len(cleaned)}")
lines.append(f"Missing SBP (mmHg): {sbp_missing}/{len(cleaned)}")
lines.append("")
# By sex
by_sex = stratify(cleaned, lambda p: p.get("sex"))
for sex in sorted(by_sex.keys()):
lines.append(report_prevalence_section(f"Prevalence by sex: {sex}", by_sex[sex]))
lines.append("")
# By age group
by_age = stratify(cleaned, lambda p: age_group(p.get("age")))
# Keep a sensible order:
order = ["<45", "45-54", "55-64", ">=65"]
for grp in order:
if grp in by_age:
lines.append(report_prevalence_section(f"Prevalence by age group: {grp}", by_age[grp]))
lines.append("")
return "\n".join(lines)Important meanings
groups.setdefault(key, []).append(p):- if
keyis missing, create an empty list - then append the patient
- this is a common “group-by” pattern (a pure Python version of what pandas does later)
- if
Step 8 — Put it all together (final script)
At the bottom of your file:
def main():
cleaned = clean_patients(PATIENTS)
text_report = build_report(cleaned)
print(text_report)
if __name__ == "__main__":
main()
Run:
python wrap_up_cardiology_audit.py
You should see an output like:
- overall prevalence of each risk factor
- missing LDL / SBP counts
- prevalence by sex and by age group
FAQ
1) Why return None instead of forcing True/False?
Because in clinical data, unknown ≠ negative. Treating unknown as False silently biases prevalence and downstream models.
2) Why copy dictionaries with dict(p)?
So you can debug and compare raw vs cleaned data. Mutating raw data makes it harder to trace errors.
3) Why exclude None from the denominator?
Prevalence should be computed on known observations unless you explicitly choose another assumption.
4) Why do we check known == 0?
To avoid division by zero and to make “no data available” an explicit state.
5) Is setdefault “Pythonic”?
Yes. It’s a standard pattern for grouping. Alternatives exist (see next FAQ).
6) What’s an alternative to setdefault?
You can use:
- an
if key not in groups:block collections.defaultdict(list)(more advanced but very clean)
7) Is this approach “good enough” for real clinical audits?
As a teaching pattern, yes. For production, you’d add:
- logging for unexpected values
- strict schema validation
- unit tests for
to_bool,age_group, etc.
8) How do I save the report to a file?
Use open("report.txt","w", encoding="utf-8") and .write(text_report) (Lesson 8).
9) How would you handle continuous variables like LDL?
Compute summary stats (mean/median/IQR) and missingness; later, use pandas or numpy.
10) What’s the most common beginner mistake here?
Assuming data is clean. Most real errors come from messy inputs, not from “wrong math”.
Exercises + Solutions
Tip: do the exercises by editing the same script. Each one is small on purpose.
Exercise 1
Modify to_bool() so that "2" becomes invalid and triggers a ValueError instead of returning None.
Solution
def to_bool(value):
if value is None:
return None
text = str(value).strip().lower()
if text in {"", "na", "n/a", "null", "none"}:
return None
if text in {"1", "y", "yes", "true", "t"}:
return True
if text in {"0", "n", "no", "false", "f"}:
return False
raise ValueError(f"Unexpected boolean encoding: {value!r}")
Exercise 2
Add a new risk factor field: family_history and include it everywhere (cleaning + reporting).
Solution
RISK_FACTORS = ["smoker", "hypertension", "diabetes", "dyslipidemia", "family_history"]
# Then ensure each patient dict has that key (or .get will return None).
# Cleaning/reporting loops already adapt because they iterate over RISK_FACTORS.
Exercise 3
Create a function patients_with_uncontrolled_bp() that returns patients with sbp_mmHg >= 140.
Solution
def patients_with_uncontrolled_bp(patients):
result = []
for p in patients:
sbp = p.get("sbp_mmHg")
if sbp is None:
continue
if sbp >= 140:
result.append(p)
return result
Exercise 4
Compute prevalence of “uncontrolled BP” (SBP ≥ 140) overall, excluding missing SBP.
Solution
def prevalence_uncontrolled_bp(patients):
positives = 0
known = 0
for p in patients:
sbp = p.get("sbp_mmHg")
if sbp is None:
continue
known += 1
if sbp >= 140:
positives += 1
return positives, known, (positives/known if known else None)
Exercise 5
Add a “high LDL” flag: LDL ≥ 160. Store it as a new boolean field high_ldl in cleaned data.
Solution
def clean_patients(raw_patients):
cleaned = []
for p in raw_patients:
new_p = dict(p)
new_p["sex"] = normalize_sex(p.get("sex"))
for rf in RISK_FACTORS:
new_p[rf] = to_bool(p.get(rf))
ldl = new_p.get("ldl_mg_dl")
new_p["high_ldl"] = (ldl >= 160) if (ldl is not None) else None
cleaned.append(new_p)
return cleaned
Exercise 6
Include high_ldl in the report (overall + stratified).
Solution
# Option A: treat it as a risk factor too
RISK_FACTORS = ["smoker", "hypertension", "diabetes", "dyslipidemia", "high_ldl"]
# Ensure cleaning sets high_ldl before reporting.Exercise 7
Add an “age ≥ 65” subgroup report.
Solution
older = [p for p in cleaned if p.get("age") is not None and p["age"] >= 65]
print(report_prevalence_section("Prevalence in age ≥ 65", older))
Exercise 8
Refactor build_report() so it returns both:
- report text
- a dict of computed metrics (for future ML pipelines)
Solution
def build_report(cleaned):
metrics = {}
# Example: store overall prevalence
for rf in RISK_FACTORS:
pos, known, prev = prevalence(cleaned, rf)
metrics[f"overall_{rf}_pos"] = pos
metrics[f"overall_{rf}_known"] = known
metrics[f"overall_{rf}_prev"] = prev
text = "..." # build your lines as before
return text, metrics
Exercise 9
Write the report to a file named cardiology_audit_report.txt.
Solution
def main():
cleaned = clean_patients(PATIENTS)
text_report = build_report(cleaned)
print(text_report)
with open("cardiology_audit_report.txt", "w", encoding="utf-8") as f:
f.write(text_report)Exercise 10
Implement a minimal “sanity check” that prints a warning if any patient has sex is None.
Solution
def warn_missing_sex(patients):
missing = [p["id"] for p in patients if p.get("sex") is None]
if missing:
print("WARNING: missing/invalid sex for:", ", ".join(missing))
def main():
cleaned = clean_patients(PATIENTS)
warn_missing_sex(cleaned)
print(build_report(cleaned))
End note: why this wrap-up matters
If you can build a clean, explicit pipeline like this in plain Python, then:
- pandas becomes easier (it’s “the same ideas, just faster”)
- data analysis is more reliable
- ML work becomes safer because your inputs are controlled
When you’re ready, the next wrap-up can evolve this into:
and (later) a simple predictive model.
a pandas DataFrame workflow,
a small visualization,
Below is the complete program. You can copy it into a Python file, run it in VS Code, and modify it to experiment with different scenarios.
"""
Wrap-up Python Crash Course
Mini Cardiology Outpatient Risk-Factor Audit
This script:
- cleans a small outpatient cardiology dataset
- normalizes messy boolean fields
- computes prevalence of cardiovascular risk factors
- stratifies results by sex and age group
- prints a human-readable audit report
Pure Python (no pandas), designed for didactic clarity.
"""
# -----------------------------
# Raw outpatient cardiology data
# -----------------------------
PATIENTS = [
{"id": "P001", "age": 54, "sex": "M", "smoker": "yes", "hypertension": "Y", "diabetes": "no", "dyslipidemia": "1", "ldl_mg_dl": 162, "sbp_mmHg": 148},
{"id": "P002", "age": 67, "sex": "F", "smoker": "no", "hypertension": "no", "diabetes": "No", "dyslipidemia": "yes", "ldl_mg_dl": 135, "sbp_mmHg": 132},
{"id": "P003", "age": 61, "sex": "female", "smoker": "0", "hypertension": "1", "diabetes": "0", "dyslipidemia": "0", "ldl_mg_dl": 118, "sbp_mmHg": 156},
{"id": "P004", "age": 45, "sex": "M", "smoker": "TRUE", "hypertension": "false", "diabetes": "false", "dyslipidemia": "true", "ldl_mg_dl": None, "sbp_mmHg": 125},
{"id": "P005", "age": 73, "sex": "F", "smoker": "n", "hypertension": "y", "diabetes": "y", "dyslipidemia": "y", "ldl_mg_dl": 104, "sbp_mmHg": None},
{"id": "P006", "age": 58, "sex": "M", "smoker": "", "hypertension": "N/A", "diabetes": "no", "dyslipidemia": "no", "ldl_mg_dl": 190, "sbp_mmHg": 140},
{"id": "P007", "age": 39, "sex": "F", "smoker": "No", "hypertension": "0", "diabetes": "0", "dyslipidemia": "0", "ldl_mg_dl": 92, "sbp_mmHg": 118},
{"id": "P008", "age": 52, "sex": "M", "smoker": "Yes", "hypertension": "Yes", "diabetes": "No", "dyslipidemia": "Yes", "ldl_mg_dl": 143, "sbp_mmHg": 151},
{"id": "P009", "age": 64, "sex": "F", "smoker": None, "hypertension": "1", "diabetes": "1", "dyslipidemia": "0", "ldl_mg_dl": 128, "sbp_mmHg": 160},
{"id": "P010", "age": 49, "sex": "M", "smoker": "0", "hypertension": "0", "diabetes": "0", "dyslipidemia": "1", "ldl_mg_dl": 155, "sbp_mmHg": 138},
]
RISK_FACTORS = ["smoker", "hypertension", "diabetes", "dyslipidemia"]
# -----------------------------
# Utility functions
# -----------------------------
def to_bool(value):
"""
Convert heterogeneous yes/no encodings into True / False / None.
"""
if value is None:
return None
text = str(value).strip().lower()
if text in {"", "na", "n/a", "null", "none"}:
return None
if text in {"1", "y", "yes", "true", "t"}:
return True
if text in {"0", "n", "no", "false", "f"}:
return False
return None
def normalize_sex(value):
"""
Normalize sex encoding to 'M', 'F', or None.
"""
if value is None:
return None
text = str(value).strip().lower()
if text in {"m", "male"}:
return "M"
if text in {"f", "female"}:
return "F"
return None
def age_group(age):
"""
Map numeric age to an age group.
"""
if age is None:
return None
if age < 45:
return "<45"
if age <= 54:
return "45-54"
if age <= 64:
return "55-64"
return ">=65"
# -----------------------------
# Data cleaning
# -----------------------------
def clean_patients(raw_patients):
"""
Return a cleaned copy of the patient list.
"""
cleaned = []
for p in raw_patients:
new_p = dict(p)
new_p["sex"] = normalize_sex(p.get("sex"))
for rf in RISK_FACTORS:
new_p[rf] = to_bool(p.get(rf))
cleaned.append(new_p)
return cleaned
# -----------------------------
# Analysis helpers
# -----------------------------
def prevalence(patients, field):
"""
Compute prevalence of a boolean field.
Returns (positives, known, prevalence).
"""
positives = 0
known = 0
for p in patients:
value = p.get(field)
if value is None:
continue
known += 1
if value is True:
positives += 1
if known == 0:
return positives, known, None
return positives, known, positives / known
def count_missing(patients, field):
"""
Count missing values for a given field.
"""
missing = 0
for p in patients:
if p.get(field) is None:
missing += 1
return missing
def stratify(patients, key_func):
"""
Group patients by a key function.
"""
groups = {}
for p in patients:
key = key_func(p)
if key is None:
continue
groups.setdefault(key, []).append(p)
return groups
def format_pct(value):
if value is None:
return "NA"
return f"{value * 100:.1f}%"
# -----------------------------
# Reporting
# -----------------------------
def report_prevalence_section(title, patients):
lines = []
lines.append(title)
lines.append("-" * len(title))
for rf in RISK_FACTORS:
pos, known, prev = prevalence(patients, rf)
lines.append(f"{rf:14s}: {pos}/{known} ({format_pct(prev)})")
return "\n".join(lines)
def build_report(cleaned):
lines = []
lines.append("CARDIOLOGY OUTPATIENT MINI-AUDIT")
lines.append("=" * 32)
lines.append(f"Total patients: {len(cleaned)}")
lines.append("")
lines.append(report_prevalence_section("Overall prevalence", cleaned))
lines.append("")
lines.append("Data quality")
lines.append("------------")
lines.append(f"Missing LDL (mg/dL): {count_missing(cleaned, 'ldl_mg_dl')}/{len(cleaned)}")
lines.append(f"Missing SBP (mmHg): {count_missing(cleaned, 'sbp_mmHg')}/{len(cleaned)}")
lines.append("")
by_sex = stratify(cleaned, lambda p: p.get("sex"))
for sex in sorted(by_sex.keys()):
lines.append(report_prevalence_section(f"Prevalence by sex: {sex}", by_sex[sex]))
lines.append("")
by_age = stratify(cleaned, lambda p: age_group(p.get("age")))
for grp in ["<45", "45-54", "55-64", ">=65"]:
if grp in by_age:
lines.append(report_prevalence_section(f"Prevalence by age group: {grp}", by_age[grp]))
lines.append("")
return "\n".join(lines)
# -----------------------------
# Main
# -----------------------------
def main():
cleaned = clean_patients(PATIENTS)
report = build_report(cleaned)
print(report)
if __name__ == "__main__":
main()
