Build a 30-Day Readmission Risk Notebook

Build a Colab notebook to predict 30-day hospital readmission risk.

Introduction

30 Second Summary

A high accuracy score can look reassuring when judging a prediction. With rare outcomes, that number can hide every case the prediction missed.

In this project, you will build a shareable Google Colab notebook for exploring 30-day hospital readmission risk. It turns historical hospital encounters into risk scores for evaluation.

What You'll Build

Your finished notebook downloads real hospital data, exposes an all-negative baseline that misses every early readmission, compares model thresholds, and prints encounter-level risk scores.

By the end of this project, you'll have:

  • A shareable hospital data notebook that downloads historical encounters from the UCI Machine Learning Repository. It prints the target counts and the 30-day readmission rate.
  • A visible accuracy trap that predicts no early readmissions for every test encounter. You will see its recall fall to zero.
  • A readmission risk workflow that keeps test data untouched until evaluation. You can compare precision across several decision thresholds before viewing ten held-out encounters.
  • Secret Mission: Audit recall across patient age groups and identify the lowest-recall group without claiming a causal explanation.

Are there any prerequisites?

You only need a Google account and a web browser. No prior Python, pandas, or machine-learning experience is required.

Before We Start

Before any notebook work begins, this checkpoint sets the boundary for what you are building. You are creating an educational 30-day readmission model from historical public data, with no role in clinical decisions.

Set Up the Colab Notebook

A reproducible analysis needs a workspace that keeps its code beside its results. Google Colab supplies a hosted notebook in your browser.

Version pinning keeps later cells on the same library behavior. A version check proves that the runtime matches the notebook requirements.

This notebook remains an educational analysis of historical public data. Clinical decisions require validated systems beyond this project's scope.

In this step, get ready to:
  • Create a Colab notebook named 30_day_readmission_risk.
  • Install the three pinned Python libraries in the Colab runtime.
  • Confirm that every installed package matches its required version.
Create the notebook

Google Colab stores notebooks in Google Drive. A clear name makes this project easy to find later.

  • Visit Google Colab in your browser.
  • Complete the Google sign-in flow if the browser asks for your account.
  • Select the blank notebook option on the landing page.

You'll see an empty notebook with its first code cell ready.

  • Click the current notebook name in the top-left corner.

The notebook name becomes editable.

  • Enter 30_day_readmission_risk as the notebook name.
  • Press Enter to apply the new name.

Your notebook now shows 30_day_readmission_risk at the top.

  • Confirm that the notebook reports your latest changes as saved.
Prepare the runtime

Three pinned libraries create the analysis environment. pandas 3.0.5 handles the notebook's tables.

scikit-learn 1.9.0 provides the model tools. ucimlrepo 0.0.7 provides access to the public dataset.

  • Select the first code cell in your notebook.
  • Install the pinned libraries by running this code in the first cell:
%pip install pandas==3.0.5 scikit-learn==1.9.0 ucimlrepo==0.0.7

What Does This Installation Cell Do?

  • The %pip notebook magic installs packages into the current Colab runtime.
  • Each == pin selects one exact package version.
  • These pins keep later notebook results easier to reproduce.
  • Wait for the installation cell to finish running.
  • Confirm that the installation output completes without an error.

That first cell has done the heavy lifting. Your pinned libraries are installed in the active runtime.

Installation Cell Did Not Finish?

  • Check that your browser still has an internet connection.
  • Rerun the installation cell if the Colab runtime briefly disconnected.
  • Make sure the package names contain the exact == version pins shown above.
  • Help me diagnose the package installation.
Verify the package versions

Installed-package metadata gives you an independent check of the runtime. The check reads each installed distribution before printing its version.

  • Add a second code cell below the installation output using the notebook toolbar.

Before you run the check, do you expect every installed version to match its pin?

  • Check the installed versions by running this code in the second cell:
from importlib.metadata import version

for package_name in ["pandas", "scikit-learn", "ucimlrepo"]:
    print(package_name, version(package_name))

What Does This Version Check Do?

  • The import brings the installed-package version lookup into the notebook.
  • The package_name loop checks each required distribution.
  • The version(package_name) call reads the version installed in the active runtime.
  • Confirm that the first output line pairs pandas with 3.0.5.
  • Confirm that the second output line pairs scikit-learn with 1.9.0.
  • Confirm that the third output line pairs ucimlrepo with 0.0.7.
  • Confirm that the notebook reports your latest changes as saved.

Your environment check has passed. The notebook now has the exact libraries required for the analysis.

Seeing a Different Package Version?

  • Rerun the installation cell to apply the pinned versions again.
  • Rerun the version-check cell after the installation finishes.
  • Compare every package name with the spelling in the installation cell.
  • Help me fix a package version mismatch.

Your versioned notebook is ready in Google Drive. Next, you'll load the hospital encounter data and test a simple baseline.

Load Data and Reveal the Accuracy Trap

Your verified package versions give this Google Colab notebook a reproducible base. Now the notebook needs real hospital encounter data.

Early readmission is the minority outcome in this dataset. An all-negative baseline can still show high accuracy. Its recall exposes that it misses every early readmission.

In this step, get ready to:
  • Load the hospital encounter dataset into the notebook.
  • Create a stratified training and test split.
  • Measure an all-negative baseline with classification metrics.
Fetch and shape the dataset

The UCI Machine Learning Repository provides dataset 296 through a Python fetch function. It returns separate feature and target tables for the notebook.

  • Add a new code cell below the version-check cell.
  • Paste this import and dataset-loading code into the new cell:
from collections import Counter

import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    average_precision_score,
    confusion_matrix,
    precision_score,
    recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# Load UCI dataset 296.
dataset = fetch_ucirepo(id=296)

What does this code do?

  • The Counter class counts the original outcome labels.
  • The pandas import provides the DataFrame tables used for readable metric output.
  • The scikit-learn imports provide splitting tools. They also provide preprocessing and evaluation tools.
  • The fetch_ucirepo() call downloads dataset 296 into dataset.
  • Run the cell with the control beside it.

The cell completes without an exception. The dataset object now holds the downloaded data.

Dataset cell not completing?

  • Confirm that the earlier package-install cell completed successfully.
  • Check that your browser still has an internet connection.
  • Run the dataset cell again after the connection recovers.
  • Help me diagnose why the UCI dataset is not loading in Google Colab.

The source contains many fields. A focused set of admission and utilization features keeps this beginner workflow readable. The target becomes binary because only "<30" represents readmission within 30 days.

  • Add a new code cell below the dataset-loading cell.
  • Paste this feature-selection and target code into the new cell:
numeric_columns = [
    "time_in_hospital",
    "num_lab_procedures",
    "num_procedures",
    "num_medications",
    "number_outpatient",
    "number_emergency",
    "number_inpatient",
    "number_diagnoses",
]
categorical_columns = [
    "age",
    "admission_type_id",
    "discharge_disposition_id",
    "admission_source_id",
]
feature_columns = numeric_columns + categorical_columns

X = dataset.data.features[feature_columns]
raw_target = list(dataset.data.targets.iloc[:, 0])
y = [1 if value == "<30" else 0 for value in raw_target]

print("Rows and selected features:", X.shape)
print("Original target counts:", Counter(raw_target))
print(f"30-day readmission rate: {sum(y) / len(y):.1%}")

How is the target prepared?

  • The numeric_columns list names eight utilization fields.
  • The categorical_columns list names four grouped or coded fields.
  • The X table contains the twelve selected features.
  • The raw_target list preserves the original readmission labels.
  • The y list assigns 1 only to outcomes labeled "<30".
  • The print statements expose the dataset shape. They also expose the original label counts and positive-class rate.
  • Run the feature-selection cell with the control beside it.

There it is. Your notebook now shows 101,766 encounters across 12 selected features. It also shows how small the early-readmission class is compared with the other outcomes.

Create the stratified split

A train-test split reserves part of the dataset for evaluation. Stratification keeps the binary class proportions similar across both portions.

  • Add a new code cell below the target-preparation cell.
  • Paste this split configuration into the new cell:
X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y,
)

What does this split protect?

  • The test_size=0.20 setting reserves 20 percent of the encounters for testing.
  • The random_state=42 setting makes the split reproducible.
  • The stratify=y setting preserves the positive-class proportion.
  • The four resulting variables keep features aligned with their corresponding outcomes.
  • Run the split cell with the control beside it.

The cell completes successfully. Your runtime now contains separate training and test variables.

Split cell showing an error?

  • Run the dataset-loading cell before running the split cell.
  • Run the target-preparation cell before running the split cell.
  • Check that X uses a capital letter.
  • Help me fix the train-test split in my readmission notebook.
Build and test the all-negative baseline

A baseline gives every later model a reference point. This baseline predicts no early readmission for every held-out encounter. Its metrics reveal the cost of ignoring the positive class.

  • Add a new code cell below the split cell.
  • Paste this evaluation helper into the new cell:
def evaluate_predictions(name, y_true, y_pred, y_score):
    accuracy = sum(
        int(actual == predicted)
        for actual, predicted in zip(y_true, y_pred)
    ) / len(y_true)
    return {
        "model": name,
        "accuracy": accuracy,
        "precision": precision_score(y_true, y_pred, zero_division=0),
        "recall": recall_score(y_true, y_pred, zero_division=0),
        "average_precision": average_precision_score(y_true, y_score),
    }

What does the helper measure?

  • The evaluate_predictions() function accepts actual outcomes. It also accepts predicted outcomes and risk scores.
  • Accuracy measures the share of all predictions that match their outcomes.
  • Precision measures how many flagged encounters are truly positive.
  • Recall measures how many true positive encounters the predictions find.
  • Average precision summarizes performance across score thresholds.
  • Run the helper cell with the control beside it.

The cell completes without printed output. The evaluate_predictions() helper is now available to the next cell.

Metrics helper not loading?

  • Match the indentation inside evaluate_predictions() to the code above.
  • Run the imports cell before defining the helper.
  • Help me find the syntax or indentation issue in my evaluation helper.
  • Add a new code cell below the evaluation-helper cell.
  • Paste this all-negative baseline into the new cell:
baseline_predictions = [0] * len(y_test)
baseline_scores = [0.0] * len(y_test)
baseline_metrics = evaluate_predictions(
    "all-negative baseline",
    y_test,
    baseline_predictions,
    baseline_scores,
)

print("\nBaseline metrics")
print(pd.DataFrame([baseline_metrics]))
print("\nBaseline confusion matrix")
print(confusion_matrix(y_test, baseline_predictions, labels=[0, 1]))

How does the baseline create the trap?

  • The baseline_predictions list predicts 0 for every test encounter.
  • The baseline_scores list assigns every encounter a risk score of 0.0.
  • The metrics table places accuracy beside the positive-class measures.
  • The confusion matrix separates correct negatives from missed positives.

Before you run the baseline, predict whether accuracy and recall will tell the same story.

  • Run the baseline cell with the control beside it.

You will see strong accuracy beside recall of 0.0. The two-row, two-column confusion matrix shows no true positives.

That mismatch is the trap you were looking for. Your baseline now proves why accuracy cannot evaluate this screening example alone.

Baseline output missing?

  • Run the split cell before running the baseline cell.
  • Run the evaluation-helper cell before running the baseline cell.
  • Check that baseline_predictions contains zeros for every item in y_test.
  • Help me debug why my baseline metrics or confusion matrix are missing.

✔️ Awesome, I've got everything!

Your notebook now prints the dataset shape, original target counts, positive-class rate, baseline metrics, and baseline confusion matrix.

ⓧ I'd like to double check the full code

Compare your notebook cells with this cumulative code. Keep each section in the same order.

from collections import Counter

import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    average_precision_score,
    confusion_matrix,
    precision_score,
    recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# Load UCI dataset 296.
dataset = fetch_ucirepo(id=296)

numeric_columns = [
    "time_in_hospital",
    "num_lab_procedures",
    "num_procedures",
    "num_medications",
    "number_outpatient",
    "number_emergency",
    "number_inpatient",
    "number_diagnoses",
]
categorical_columns = [
    "age",
    "admission_type_id",
    "discharge_disposition_id",
    "admission_source_id",
]
feature_columns = numeric_columns + categorical_columns

X = dataset.data.features[feature_columns]
raw_target = list(dataset.data.targets.iloc[:, 0])
y = [1 if value == "<30" else 0 for value in raw_target]

print("Rows and selected features:", X.shape)
print("Original target counts:", Counter(raw_target))
print(f"30-day readmission rate: {sum(y) / len(y):.1%}")

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y,
)


def evaluate_predictions(name, y_true, y_pred, y_score):
    accuracy = sum(
        int(actual == predicted)
        for actual, predicted in zip(y_true, y_pred)
    ) / len(y_true)
    return {
        "model": name,
        "accuracy": accuracy,
        "precision": precision_score(y_true, y_pred, zero_division=0),
        "recall": recall_score(y_true, y_pred, zero_division=0),
        "average_precision": average_precision_score(y_true, y_score),
    }


baseline_predictions = [0] * len(y_test)
baseline_scores = [0.0] * len(y_test)
baseline_metrics = evaluate_predictions(
    "all-negative baseline",
    y_test,
    baseline_predictions,
    baseline_scores,
)

print("\nBaseline metrics")
print(pd.DataFrame([baseline_metrics]))
print("\nBaseline confusion matrix")
print(confusion_matrix(y_test, baseline_predictions, labels=[0, 1]))

Your notebook now exposes the all-negative baseline's zero recall. Next, you will train a pipeline that can learn minority-class patterns.

Train a Leakage-Safe Pipeline

Your all-negative baseline in Google Colab exposed the accuracy trap. It missed every early readmission in the held-out data.

The notebook now needs a model that can learn minority-class patterns. The test split must remain untouched until evaluation.

Data leakage happens when held-out information influences training. A preprocessing pipeline protects that boundary by learning every transformation from the training split.

Scikit-learn links preprocessing with logistic regression inside one model. Fitting that model on X_train keeps information from X_test out of the learned transformations.

In this step, get ready to:
  • Create separate preprocessing branches for numeric features and categorical features.
  • Combine both branches with balanced logistic regression inside one pipeline.
  • Fit the pipeline on training data to generate held-out risk scores.
Build the preprocessing branches

Numeric values can have missing entries. Their scales can also differ widely across columns.

  • Add a code cell below the baseline confusion matrix output.
  • Paste the numeric transformer into the new cell:
numeric_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]
)

What does this code do?

  • The SimpleImputer fills missing numeric values using medians learned during training.
  • The StandardScaler learns a consistent scale for each numeric column.
  • The Pipeline applies imputation before scaling whenever numeric data passes through it.
  • Run the cell.
  • Confirm Google Colab marks the cell as completed without an error.

The numeric branch is now defined.

Numeric branch not completing?

  • Rerun the imports cell if Colab reports a missing name.
  • Check each pair of parentheses if Colab points to a syntax problem.
  • Help me debug the numeric preprocessing branch.

Categorical columns need a different strategy. They contain labels that the classifier must receive as numeric inputs.

  • Add a code cell below the numeric transformer cell.
  • Paste the categorical transformer into the new cell:
categorical_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ]
)

How are categories prepared?

  • The most_frequent strategy fills each missing category with the most common training value from its column.
  • The OneHotEncoder converts category labels into numeric indicator columns.
  • The handle_unknown="ignore" setting lets the model process a test category that was absent from training.
  • Run the categorical transformer cell.
  • Confirm Google Colab marks the cell as completed without an error.

Both preprocessing branches are now defined.

Categorical branch not completing?

  • Rerun the imports cell if SimpleImputer or OneHotEncoder is unavailable.
  • Compare the strategy value with most_frequent if Colab reports a parameter problem.
  • Help me debug the categorical preprocessing branch.
Combine preprocessing with the classifier

The numeric branch belongs only on numeric_columns. The categorical branch belongs only on categorical_columns.

  • Add a code cell below the categorical transformer cell.
  • Paste the column-routing transformer into the new cell:
preprocessor = ColumnTransformer(
    transformers=[
        ("numeric", numeric_transformer, numeric_columns),
        ("categorical", categorical_transformer, categorical_columns),
    ]
)

How does the routing work?

  • The ColumnTransformer sends numeric columns through numeric_transformer.
  • It sends categorical columns through categorical_transformer.
  • The transformed outputs become one feature set for the classifier.
  • Run the preprocessor cell.
  • Confirm Google Colab marks the cell as completed without an error.

Preprocessor not completing?

  • Rerun both transformer cells if Colab reports a missing variable.
  • Check that numeric_columns matches the numeric branch.
  • Help me debug the ColumnTransformer.

The classifier needs the same preprocessing during training and scoring. One outer pipeline preserves that sequence.

  • Add a code cell below the preprocessor cell.
  • Paste the model pipeline into the new cell:
model = Pipeline(
    steps=[
        ("preprocessor", preprocessor),
        (
            "classifier",
            LogisticRegression(class_weight="balanced", max_iter=300),
        ),
    ]
)

Why logistic regression?

  • The preprocessor transforms each input before the classifier receives it.
  • The class_weight="balanced" setting gives the minority class more influence during fitting.
  • Logistic regression produces probability scores with low conceptual overhead.
  • A tree ensemble would add tuning work that distracts from the evaluation lesson.
  • Run the model definition cell.
  • Confirm Google Colab marks the cell as completed without an error.

The full leakage-safe model is now defined.

Model definition not completing?

  • Rerun the preprocessor cell if Colab reports that preprocessor is unavailable.
  • Check that LogisticRegression uses class_weight="balanced".
  • Help me debug the model pipeline.
Fit the model and generate risk scores

Fitting the outer pipeline teaches every preprocessing step from X_train alone. The untouched X_test encounters enter the pipeline only when you request scores.

  • Add a code cell below the model definition.
  • Paste the fitting code into the new cell:
model.fit(X_train, y_train)
model_scores = model.predict_proba(X_test)[:, 1]
print(f"\nModel fitted; generated {len(model_scores)} risk scores.")

What happens during fitting?

  • The fit call learns imputations from the training data.
  • The same call learns scaling values from the training data.
  • The classifier learns its coefficients from the transformed training rows.
  • The predict_proba call returns the positive-class score for every held-out encounter.

Before you run this cell, how many risk scores do you expect compared with the number of rows in X_test?

  • Run the training cell.

You'll see a line beginning with Model fitted. The generated count matches the number of held-out encounters in X_test.

That is the core training loop working. Your untouched test encounters now have model-generated risk scores.

Training output missing?

  • Rerun every cell from the imports through the model definition if Colab reports a missing name.
  • Check that model.fit uses X_train with y_train.
  • Help me debug the training cell.

✔️ Awesome, I've got everything!

Your fitted pipeline now produces one positive-class risk score for every held-out encounter.

ⓧ I'd like to double check the full code

  • Compare your notebook cells with this cumulative reference:
from collections import Counter

import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    average_precision_score,
    confusion_matrix,
    precision_score,
    recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# Load UCI dataset 296.
dataset = fetch_ucirepo(id=296)

numeric_columns = [
    "time_in_hospital",
    "num_lab_procedures",
    "num_procedures",
    "num_medications",
    "number_outpatient",
    "number_emergency",
    "number_inpatient",
    "number_diagnoses",
]
categorical_columns = [
    "age",
    "admission_type_id",
    "discharge_disposition_id",
    "admission_source_id",
]
feature_columns = numeric_columns + categorical_columns

X = dataset.data.features[feature_columns]
raw_target = list(dataset.data.targets.iloc[:, 0])
y = [1 if value == "<30" else 0 for value in raw_target]

print("Rows and selected features:", X.shape)
print("Original target counts:", Counter(raw_target))
print(f"30-day readmission rate: {sum(y) / len(y):.1%}")

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y,
)


def evaluate_predictions(name, y_true, y_pred, y_score):
    accuracy = sum(
        int(actual == predicted)
        for actual, predicted in zip(y_true, y_pred)
    ) / len(y_true)
    return {
        "model": name,
        "accuracy": accuracy,
        "precision": precision_score(y_true, y_pred, zero_division=0),
        "recall": recall_score(y_true, y_pred, zero_division=0),
        "average_precision": average_precision_score(y_true, y_score),
    }


baseline_predictions = [0] * len(y_test)
baseline_scores = [0.0] * len(y_test)
baseline_metrics = evaluate_predictions(
    "all-negative baseline",
    y_test,
    baseline_predictions,
    baseline_scores,
)

print("\nBaseline metrics")
print(pd.DataFrame([baseline_metrics]))
print("\nBaseline confusion matrix")
print(confusion_matrix(y_test, baseline_predictions, labels=[0, 1]))

numeric_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]
)

categorical_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ]
)

preprocessor = ColumnTransformer(
    transformers=[
        ("numeric", numeric_transformer, numeric_columns),
        ("categorical", categorical_transformer, categorical_columns),
    ]
)

model = Pipeline(
    steps=[
        ("preprocessor", preprocessor),
        (
            "classifier",
            LogisticRegression(class_weight="balanced", max_iter=300),
        ),
    ]
)

model.fit(X_train, y_train)
model_scores = model.predict_proba(X_test)[:, 1]
print(f"\nModel fitted; generated {len(model_scores)} risk scores.")

Your model now produces held-out readmission risk scores. Next up, you'll compare decision thresholds to make the precision-versus-recall trade-off visible.

Compare Thresholds and Build the Risk Table

The fitted Pipeline now gives every held-out encounter a risk score. A score alone does not choose the screening trade-off.

A decision threshold converts that score into a higher-risk flag. You'll compare precision with recall at three thresholds before building the final encounter table.

In this step, get ready to:
  • Compare precision with recall at thresholds 0.3, 0.4, and 0.5.
  • Evaluate the 0.4 threshold against the all-negative baseline.
  • Display ten held-out encounters with risk scores plus higher-risk flags.
Compare decision thresholds

Each threshold turns a continuous risk score into a class of 1 or 0. Lower thresholds usually flag more encounters as positive.

The comparison table makes the resulting precision-versus-recall trade-off visible.

  • In the Google Colab notebook from earlier, add a code cell below the cell that prints the number of generated risk scores.
  • Paste the following threshold comparison code into the new cell:
threshold_rows = []
for threshold in [0.30, 0.40, 0.50]:
    predictions = (model_scores >= threshold).astype(int)
    threshold_rows.append(
        {
            "threshold": threshold,
            "precision": precision_score(y_test, predictions, zero_division=0),
            "recall": recall_score(y_test, predictions, zero_division=0),
        }
    )

print("\nThreshold comparison")
print(pd.DataFrame(threshold_rows))

What does this code do?

  • The threshold_rows list stores one results record for each threshold.
  • Each predictions array flags scores that meet or exceed the current threshold.
  • The precision_score() result measures how many flagged encounters have the positive outcome.
  • The recall_score() result measures how many positive outcomes the model flags.

Before you run the cell, which threshold do you expect to produce the highest recall?

  • Run the cell with the play icon beside it.

You'll see a three-row table for thresholds 0.3, 0.4, and 0.5. Each row shows its precision plus recall.

Good progress. Your notebook now exposes the threshold trade-off in one table.

Threshold table missing?

  • Confirm the earlier training cell completed so that model_scores exists.
  • Check that threshold_rows is defined before the loop begins.
  • Check the indentation inside the for loop against the code above.
  • Help me debug my threshold comparison cell.
Evaluate the chosen threshold

The notebook uses 0.4 as a demonstration threshold for comparing model behavior. Clinical use would require calibration plus external validation.

This evaluation applies the same metrics used for the baseline. It also adds average precision plus a final confusion matrix.

  • Add a code cell below the threshold comparison output.
  • Paste the following final-metrics code into the new cell:
chosen_threshold = 0.40
final_predictions = (model_scores >= chosen_threshold).astype(int)
model_metrics = evaluate_predictions(
    "balanced logistic regression",
    y_test,
    final_predictions,
    model_scores,
)

print("\nBaseline versus model")
print(pd.DataFrame([baseline_metrics, model_metrics]))
print("\nFinal confusion matrix")
print(confusion_matrix(y_test, final_predictions, labels=[0, 1]))

How does this evaluate the model?

  • The chosen_threshold value fixes the notebook's demonstration cutoff at 0.40.
  • The evaluate_predictions() helper calculates the model metrics with the existing test labels.
  • Average precision summarizes the precision-recall curve using the unthresholded model_scores values.
  • The confusion_matrix() result separates true negatives from false positives. It also separates false negatives from true positives.

Before you run this cell, do you expect the model to recover more positive cases than the all-negative baseline?

  • Run the cell with the play icon beside it.

You'll see a baseline-versus-model metrics table that includes average precision. Below it, you'll see a final 2 by 2 confusion matrix.

Final metrics not appearing?

  • Confirm the threshold comparison cell ran after the model-training cell.
  • Check that the existing helper is named evaluate_predictions().
  • Confirm the selected cutoff is written as chosen_threshold = 0.40.
  • Help me debug my final model evaluation.
Build the risk table

A pandas DataFrame connects the aggregate metrics to individual held-out encounters. Each row keeps the age group beside the actual outcome plus the model result.

  • Add a code cell below the final confusion matrix output.
  • Paste the following encounter-table code into the new cell:
prediction_table = pd.DataFrame(
    {
        "age_group": list(X_test["age"]),
        "actual_readmission_within_30_days": y_test,
        "risk_score": [round(float(score), 3) for score in model_scores],
        "higher_risk_flag": final_predictions,
    }
)

print("\nSample risk table")
print(prediction_table[:10])

What does the risk table show?

  • The age_group column comes from the untouched test features.
  • The actual_readmission_within_30_days column shows the known binary outcome.
  • The risk_score column rounds each positive-class score to three decimal places.
  • The higher_risk_flag column records the classification produced by the chosen threshold.
  • The prediction_table[:10] slice keeps the displayed sample compact.

Before you run the final cell, do you expect every higher-risk flag to match the actual outcome?

  • Run the cell with the play icon beside it.

You'll see ten encounter-level rows with age_group, actual_readmission_within_30_days, risk_score, and higher_risk_flag.

Your output now includes all three thresholds. It also includes the final 2 by 2 confusion matrix plus ten risk rows.

Risk table not displaying?

  • Confirm the final evaluation cell created final_predictions before this cell runs.
  • Check that the column name inside X_test["age"] matches the code above.
  • Confirm all four table columns contain the same number of values.
  • Help me debug my prediction table.

✔️ Awesome, I've got everything!

Your saved notebook now compares three thresholds. It also displays the model metrics, final confusion matrix, and ten-row risk table.

ⓧ I'd like to double check the full code

Compare your cumulative notebook code with this complete reference:

from collections import Counter

import pandas as pd
from ucimlrepo import fetch_ucirepo
from sklearn.compose import ColumnTransformer
from sklearn.impute import SimpleImputer
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    average_precision_score,
    confusion_matrix,
    precision_score,
    recall_score,
)
from sklearn.model_selection import train_test_split
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import OneHotEncoder, StandardScaler

# Load UCI dataset 296.
dataset = fetch_ucirepo(id=296)

numeric_columns = [
    "time_in_hospital",
    "num_lab_procedures",
    "num_procedures",
    "num_medications",
    "number_outpatient",
    "number_emergency",
    "number_inpatient",
    "number_diagnoses",
]
categorical_columns = [
    "age",
    "admission_type_id",
    "discharge_disposition_id",
    "admission_source_id",
]
feature_columns = numeric_columns + categorical_columns

X = dataset.data.features[feature_columns]
raw_target = list(dataset.data.targets.iloc[:, 0])
y = [1 if value == "<30" else 0 for value in raw_target]

print("Rows and selected features:", X.shape)
print("Original target counts:", Counter(raw_target))
print(f"30-day readmission rate: {sum(y) / len(y):.1%}")

X_train, X_test, y_train, y_test = train_test_split(
    X,
    y,
    test_size=0.20,
    random_state=42,
    stratify=y,
)


def evaluate_predictions(name, y_true, y_pred, y_score):
    accuracy = sum(
        int(actual == predicted)
        for actual, predicted in zip(y_true, y_pred)
    ) / len(y_true)
    return {
        "model": name,
        "accuracy": accuracy,
        "precision": precision_score(y_true, y_pred, zero_division=0),
        "recall": recall_score(y_true, y_pred, zero_division=0),
        "average_precision": average_precision_score(y_true, y_score),
    }


baseline_predictions = [0] * len(y_test)
baseline_scores = [0.0] * len(y_test)
baseline_metrics = evaluate_predictions(
    "all-negative baseline",
    y_test,
    baseline_predictions,
    baseline_scores,
)

print("\nBaseline metrics")
print(pd.DataFrame([baseline_metrics]))
print("\nBaseline confusion matrix")
print(confusion_matrix(y_test, baseline_predictions, labels=[0, 1]))

numeric_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="median")),
        ("scaler", StandardScaler()),
    ]
)

categorical_transformer = Pipeline(
    steps=[
        ("imputer", SimpleImputer(strategy="most_frequent")),
        ("encoder", OneHotEncoder(handle_unknown="ignore")),
    ]
)

preprocessor = ColumnTransformer(
    transformers=[
        ("numeric", numeric_transformer, numeric_columns),
        ("categorical", categorical_transformer, categorical_columns),
    ]
)

model = Pipeline(
    steps=[
        ("preprocessor", preprocessor),
        (
            "classifier",
            LogisticRegression(class_weight="balanced", max_iter=300),
        ),
    ]
)

model.fit(X_train, y_train)
model_scores = model.predict_proba(X_test)[:, 1]
print(f"\nModel fitted; generated {len(model_scores)} risk scores.")

threshold_rows = []
for threshold in [0.30, 0.40, 0.50]:
    predictions = (model_scores >= threshold).astype(int)
    threshold_rows.append(
        {
            "threshold": threshold,
            "precision": precision_score(y_test, predictions, zero_division=0),
            "recall": recall_score(y_test, predictions, zero_division=0),
        }
    )

print("\nThreshold comparison")
print(pd.DataFrame(threshold_rows))

chosen_threshold = 0.40
final_predictions = (model_scores >= chosen_threshold).astype(int)
model_metrics = evaluate_predictions(
    "balanced logistic regression",
    y_test,
    final_predictions,
    model_scores,
)

print("\nBaseline versus model")
print(pd.DataFrame([baseline_metrics, model_metrics]))
print("\nFinal confusion matrix")
print(confusion_matrix(y_test, final_predictions, labels=[0, 1]))

prediction_table = pd.DataFrame(
    {
        "age_group": list(X_test["age"]),
        "actual_readmission_within_30_days": y_test,
        "risk_score": [round(float(score), 3) for score in model_scores],
        "higher_risk_flag": final_predictions,
    }
)

print("\nSample risk table")
print(prediction_table[:10])

How to use this reference

The comments mark the dataset-loading section. The remaining blocks follow the notebook from target creation through final evaluation.

That's the core notebook complete. You can now show how threshold selection changes an imbalanced classifier's results without treating its scores as clinical decisions.

Secret mission

Audit Recall by Age Group

Overall recall can hide weaker performance within individual age groups. Audit the held-out predictions to find the lowest-recall group while keeping your conclusion descriptive.

Clean Up Your Resources

Clean Up Your Resources

Your notebook is saved in Google Drive at no charge. The temporary Google Colab runtime leaves no ongoing cloud infrastructure after disconnection.

Choose whether to keep the notebook, pause your work, or delete it entirely.

Resources you used:

  • Saved notebook named 30_day_readmission_risk in Google Drive.
  • Temporary Google Colab runtime containing the installed package environment.
  • Downloaded hospital encounter dataset stored in the temporary runtime.

Keep everything running

No action is needed. Choose this option if you want to reopen the notebook, rerun the analysis, or share it.

  • Keep 30_day_readmission_risk in Google Drive.
  • Expect the temporary runtime to disconnect after inactivity.
  • Rerun the package installation cell if Colab starts a new runtime.
  • Rerun the remaining notebook cells to recreate the downloaded dataset.

Pause - I'll come back to this later

Pausing preserves your notebook while allowing the temporary runtime to disconnect.

  • Close the Google Colab notebook tab.
  • Leave 30_day_readmission_risk saved in Google Drive.
  • Allow the temporary runtime to disconnect automatically.

Your notebook remains saved. The temporary package environment disappears after Colab removes the runtime.

Delete - I don't want to use this again

This option permanently removes the saved notebook. Colab removes the temporary runtime state after disconnection.

  • Open Google Drive in your browser.
  • Locate 30_day_readmission_risk.
  • Right-click the notebook to open its context menu.
  • Select the delete option to move the notebook to trash.

The notebook leaves its original Google Drive folder. Its temporary runtime requires no separate deletion command.

  • Open Google Drive's trash.
  • Locate 30_day_readmission_risk.
  • Right-click the notebook to open its context menu.
  • Select the permanent delete option.
  • Close the Google Colab notebook tab.

After the runtime disconnects, Colab automatically removes its pinned packages and downloaded dataset.

  • Return to Google Drive's trash.
  • Confirm that 30_day_readmission_risk no longer appears.

Nice Work!

Nice Work!

You did it! Your saved Google Colab notebook now demonstrates an educational readmission risk-screening workflow that keeps clinical decisions outside its scope.

You've learned how to:

  • Load a real hospital encounter dataset with pandas. You converted its original outcome into a binary 30-day readmission target. You preserved the positive-class balance with a stratified train-test split.
  • Reveal the accuracy trap caused by class imbalance. The all-negative baseline reached zero recall. Precision measured how often positive predictions were correct. Average precision summarized the ranked risk scores. A confusion matrix exposed each prediction outcome.
  • Train a leakage-safe preprocessing pipeline. Balanced logistic regression generated held-out risk scores. You compared decision thresholds. The final risk table linked each score to an encounter.
  • Secret Mission: Audit recall across patient age groups. You identified the lowest-recall group without making a causal claim.

Ready to quiz yourself?