micheledpierri.com

  • HOME
    • Python
    • Statistics
    • Data Analysis
    • Machine Learning
  • WRITINGS
  • VISIONS
  • ABOUT
Home / Python / Python Wrap-Up Lesson: A Mini Cardiology Risk-Factor Audit (Step-by-Step)
Children dressed in lab coats are studying a python

Python Wrap-Up Lesson: A Mini Cardiology Risk-Factor Audit (Step-by-Step)

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:

  1. Loads a mini dataset (embedded in the code, no external files needed)
  2. Validates + cleans inconsistent values (e.g., "yes", "Y", "1", "true")
  3. Computes prevalence of major risk factors
  4. Stratifies results by sex and age group
  5. 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 id
  • age: years
  • sex: "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__": ensures main() runs only when you execute the file directly
  • len(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:

  • True
  • False
  • None (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 like 1 become strings, so we can normalize them consistently.
  • .strip() removes surrounding spaces.
  • .lower() makes "TRUE" equal to "true".
  • The function returns None when 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") returns None instead 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

  • continue jumps 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:

  • <45
  • 45–54
  • 55–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 key is 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)

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:

  1. report text
  2. 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()

Cite this article

Pierri, M. D. (2026). Python Wrap-Up Lesson: A Mini Cardiology Risk-Factor Audit (Step-by-Step). micheledpierri.com. Permalink

Share:Email·LinkedIn
Previous← Intermediate Python: Writing Clean, Pythonic Code
Python
  1. Why Python
  2. Python & VS Code Setup (From Zero to a Professional Environment)
  3. Variables, Naming Rules, and Basic Syntax
  4. Core Data Types in Python
  5. Control Flow: Conditions and Loops
  6. Functions and Code Reusability
  7. Collections: Lists, Tuples, Sets, and Dictionaries
  8. Modules, Packages, and File Handling
  9. Errors, Exceptions, and Robust Code
  10. Object-Oriented Programming (OOP) in Python
  11. Intermediate Python: Writing Clean, Pythonic Code
  12. Python Wrap-Up Lesson: A Mini Cardiology Risk-Factor Audit (Step-by-Step)
© 2024–2026 micheledpierri.com · Privacy Policy · Impressum