A parent sphere passing rays to child spheres, an analogy for subclassing a scikit-learn estimator

Extending scikit-learn model functionality through inheritance

Scikit-learn provides a well-designed and consistent framework for building machine learning models. In practice, however, many real-world problems require behavior that goes beyond the default implementations. This is particularly common in applied domains such as healthcare, where model decisions must align with specific operational or clinical constraints. Inheritance is a practical and reliable way to extend existing models without sacrificing the structure and interoperability that make scikit-learn so useful.

Inheritance in practice

Inheritance is a fundamental concept in object-oriented programming. It allows a new class to build on top of an existing one, reusing its functionality while introducing targeted changes. The original class acts as the foundation, and the new class adds or modifies behavior where needed.
In a machine learning workflow, this approach provides clear advantages:

• Efficiency: Reuses tested and optimized implementations
• Consistency: Preserves compatibility with pipelines and evaluation tools
• Flexibility: Enables precise control over model behavior

Scikit-learn’s estimator design makes inheritance especially effective. All models follow a common interface, with methods such as fit, predict, and predict_proba. That uniformity is what lets an extended model drop into the rest of the library without special handling.

Figure 1 makes this division of labor concrete using the extended estimator built later in this article, a subclass of LogisticRegression named LR_w_thresh. Select any method to see whether the child inherits it unchanged, redefines it, or adds it from scratch.

Figure 1 · Class anatomy

What a subclass reuses, changes, and adds

Select any method to see where it comes from and whether it calls the parent through super().

LogisticRegression parent

Supplies these methods to the child unchanged

LR_w_thresh child

Redefines a few methods and adds one

  • InheritedUsed exactly as defined by the parent. The child adds no code for it.
  • OverriddenRedefined in the child. It may still call the parent through super().
  • AddedBrand new in the child. It does not exist on the parent.

Overriddenfit

Overridden, extends the parent · Both classes define it.

Runs the parent fit unchanged to learn coef_ and intercept_, then tunes the threshold when none was set. Returns self.

super().fit(X, y, sample_weight)
if self.threshold is None:
    self._tune_threshold_PPV(X, y)
return self

Class definition versus a fitted instance

Before fit the instance holds the parameters you set, plus the random_state_ object this constructor derives from random_state. There is no coef_ yet, so calling predict would fail.

  • thresholdconstructor parameter
  • PPV_targetconstructor parameter
  • downsample_prevalenceconstructor parameter
Figure 1. LR_w_thresh inherits every method of LogisticRegression, then overrides __init__, fit, and predict and adds the helper _tune_threshold_PPV. Learned attributes such as coef_ and the tuned threshold appear only after fit, and never on the class itself.

The point to notice is how little the subclass rewrites. The parent still supplies fitting, probability estimates, scoring, and parameter handling, while the child changes only the decision step and adds one helper. Nothing in the class definition holds trained values. Attributes such as coef_ and the tuned threshold appear only after fit has run on data, which is the difference between a class and a fitted instance.

A practical motivation: decision thresholds

Standard classifiers fix the decision threshold and leave it there. Logistic regression calls anything above a probability of 0.5 positive. That cutoff suits plenty of problems and fails plenty of others. Clinical work usually wants a particular balance of precision and recall. Hold positive predictive value above some floor, then take whatever sensitivity that allows. Those requirements come from real-world consequences, which abstract performance metrics rarely capture.

The standard implementation offers no direct route to that constraint. Inheritance does.

Extending model behavior without rewriting it

Rather than build a custom model from scratch, extend an existing estimator. Inherit from logistic regression and the learning process stays exactly as it was. Only the step that turns probabilities into decisions changes.
A typical extension involves two key adjustments:

• Post-training calibration
Once the model is fitted, sweep its predicted probabilities across a range of thresholds and pick one against a stated criterion, say a target precision.

• Customized prediction logic
The model uses this learned threshold instead of the default value when generating class labels.
The statistical core goes untouched. Only the decision logic is new.

Figure 2 traces these two adjustments as they run. It follows a single call to fit and then predict, showing where control passes to the parent through super() and where the child takes over. Change the target precision to watch the selected threshold move along the precision-recall curve.

Figure 2 · Execution trace

What runs when you call fit and predict

Step through the sequence. Blue stages run in the parent through super(). Amber stages run in the child. Change the positive predictive value (PPV) target to see the threshold move.

PPV target
_tune_threshold_PPV(X, y)runs in the child

The child reads the precision-recall curve and keeps the most sensitive threshold whose precision still meets the target, falling back to the highest-precision point when none does.

Recall (sensitivity)Precision (PPV)target 0.801

Illustrative data, 22 positive and 26 negative cases.

0.49tuned threshold
0.81precision (target 0.8)
0.77recall
Figure 2. A call to fit runs the parent fit through super() to learn the coefficients, then the child tunes the decision threshold to the most sensitive value that still meets the target precision, falling back to the highest-precision point when none does. At prediction time the child reuses the parent probabilities and applies that threshold in place of the default 0.5. Precision-recall values use illustrative data.

Reading the trace in order clarifies what happens when. The parent fit does the statistical work, threshold tuning runs once immediately after, and prediction reuses the parent probabilities rather than retraining. The tuned threshold is what changes the labels, with the coefficients left where they were, and raising the target precision buys higher confidence at the cost of recall.

Aligning models with real-world conditions

Another important consideration is how model performance translates to deployment environments. Training data often differs from real-world distributions, particularly in terms of class imbalance.

Inheritance allows additional functionality to be incorporated during threshold calibration. For example, the data used to select a threshold can be adjusted to reflect a target prevalence. The adjustment keeps performance metrics meaningful once the model is in use.
Extending the base estimator keeps these kinds of adjustments inside the estimator interface rather than in surrounding script code.

Integration with existing workflows

One of the strongest advantages of this approach is that the extended model remains compatible with scikit-learn tools. It can still be used within pipelines and evaluated through cross-validation, because the estimator interface is preserved. Model selection procedures ask for more, as Figure 3 sets out. The constructor must store every parameter unchanged as well. Only specific behaviors are modified, while the overall structure remains intact.

This compatibility is critical in production environments, where reproducibility and standardization are essential.

Figure 3 turns this compatibility into a concrete checklist. Part A pairs each rule of the estimator contract with a safe pattern and a risky one, and Part B shows when subclassing is the right tool and when composition is safer.

Figure 3 · Compatibility and design choice

Staying compatible, and when to subclass or compose

Part A: pick a rule of the estimator contract to compare a safe pattern with a risky one. Part B: pick a goal to see which extension strategy fits.

A. The estimator contract

Keeps it compatible

def __init__(self, C=1.0, max_iter=100):
    ...

Puts compatibility at risk

def __init__(self, **kwargs):
    super().__init__(**kwargs)

get_params inspects the constructor signature. Parameters hidden behind **kwargs are invisible to it, so clone and GridSearchCV silently drop them.

B. Choosing an extension strategy

Subclass an estimatorinheritance

Inherit from the estimator and override only what must change. Keep the constructor contract intact.

Build from base classesfrom scratch

Start from BaseEstimator and a mixin when the algorithm is genuinely new.

Compose a meta-estimatorcomposition

Wrap an existing estimator. scikit-learn ships TunedThresholdClassifierCV and FixedThresholdClassifier for exactly the threshold case (added in 1.5).

The learning stays the same and only the cut-off changes. Wrapping the classifier keeps parameters clean.

TunedThresholdClassifierCV(estimator=LogisticRegression())
Figure 3. Inheriting from an estimator preserves the interface, so pipelines and cross-validation accept the subclass. Full support for clone and GridSearchCV additionally needs the constructor to store every parameter unchanged under its own name. When only the decision threshold changes, scikit-learn also ships a meta-estimator for exactly that case, TunedThresholdClassifierCV, which wraps the classifier instead.

Preserving the interface is what lets the subclass drop into a pipeline or a cross-validation loop. Reliable behavior under clone and GridSearchCV depends on the constructor as well, which is why parameters are stored unchanged and learned values carry a trailing underscore. When only the threshold needs adjusting, scikit-learn now offers a composition route, which selects the cutoff that maximizes a scorer and so needs a minimum-precision requirement expressed as a custom scorer, through TunedThresholdClassifierCV.

Best practices for extending scikit-learn estimators

When extending scikit-learn models through inheritance, a few principles help maintain clarity and reliability:

• Preserve base functionality
The original model behavior should remain intact unless there is a clear reason to modify it

• Follow naming conventions
Learned attributes should be clearly identified and consistently named

• Keep extensions focused
Each modification should address a specific need rather than introducing broad changes

• Document behavior explicitly

Any deviation from the base model should be clearly explained, especially in regulated settings
These practices help keep custom models understandable and maintainable over time.

Inheritance is most effective when the base model already solves the core learning problem and only specific aspects of its behavior need to be adapted. It is less suitable when the modeling approach itself needs to change fundamentally. In those cases, building a custom estimator from first principles may be more appropriate.

Example: Extending the functionality of logistic regression

Extending Estimators. How object-oriented inheritance extends scikit-learn's LogisticRegression with automatic threshold tuning and prevalence downsampling, while staying pipeline compatible. Key topics covered: Inheritance design, Beyond the 0.5 cutoff, Threshold tuning, Fit, then tune, Held-out calibration, Prevalence downsampling, Scikit-learn compatible, Test PPV achieved.

Hover any card to explore

In the code below, we will introduce a modified version of logistic regression that focuses on how predictions are used, rather than how the model is trained. It builds directly on top of Scikit-learn’s standard implementation and adds a layer of decision control that is often required in applied settings. At a high level, the class follows a simple idea. First, it trains a standard logistic regression model. Then, instead of using the default probability cutoff to assign class labels, it learns a custom threshold that satisfies a predefined constraint.

The training process occurs in two stages:
1. Standard model fitting
The logistic regression model is trained using the provided data, just as in the base implementation.

2. Threshold tuning
After training, the model evaluates its predicted probabilities and examines how precision and recall change across different thresholds. It then selects a threshold that satisfies the target PPV.

This separation is deliberate. The statistical model remains unchanged, and only the decision rule is adapted.

An additional feature of the implementation is the option to adjust the class balance during threshold tuning.
If a target prevalence is specified, the model temporarily downsamples the negative class before evaluating thresholds. This creates a dataset with a controlled proportion of positive cases. The goal is to make threshold selection more representative of real-world deployment conditions. This adjusts the base rate only. It assumes patients of each class look the same in the new setting as in this cohort, so a ward whose negatives present more like cases will fall short of the target PPV even at the re-prevalenced threshold. In many applications, especially in healthcare, the training data may not reflect the true prevalence of the condition being modeled.

Python
"""
Logistic Regression with Threshold Tuning and Downsampling

This module provides a custom LogisticRegression class that extends sklearn's 
LogisticRegression to add threshold tuning capabilities for achieving a target 
Positive Predictive Value (PPV) and optional downsampling to a specified prevalence.
"""

import numpy as np
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import precision_recall_curve


class LR_w_thresh(LogisticRegression):
    """
    Logistic Regression with threshold tuning to achieve a target PPV.
    
    This class extends sklearn's LogisticRegression to add:
    1. Custom threshold tuning to achieve a target Positive Predictive Value (PPV)
    2. Optional downsampling to a specified prevalence for threshold tuning
    
    The model fits using standard logistic regression, then tunes the decision
    threshold to maximize recall while maintaining the target PPV.
    
    Parameters
    ----------
    threshold : float or None, default=None
        Decision threshold for classification. If None, will be tuned based on PPV_target.
    PPV_target : float or None, default=None
        Target Positive Predictive Value (precision) to achieve. Required if threshold is None.
    downsample_prevalence : float or None, default=None
        Target prevalence of positive class for threshold tuning. If specified,
        the negative class will be downsampled to achieve this prevalence.
    **kwargs : dict
        Additional parameters passed to sklearn.linear_model.LogisticRegression
    
    Attributes
    ----------
    threshold : float
        The tuned decision threshold after fitting
    random_state_ : np.random.RandomState
        Random state for downsampling operations
    
    Examples
    --------
    >>> from sklearn.datasets import make_classification
    >>> X, y = make_classification(n_samples=1000, n_features=20, n_classes=2)
    >>> clf = LR_w_thresh(PPV_target=0.8, downsample_prevalence=0.3)
    >>> clf.fit(X, y)
    >>> predictions = clf.predict(X)
    """
    
    def __init__(self, threshold=None, PPV_target=None, downsample_prevalence=None, **kwargs):
        # Store custom parameters for threshold tuning and downsampling
        self.threshold = threshold
        self.PPV_target = PPV_target
        self.downsample_prevalence = downsample_prevalence  # Renamed to reflect its purpose
        
        # Extract random_state from kwargs or create new RandomState
        rs = kwargs.get('random_state')
        self.random_state_ = rs if isinstance(rs, np.random.RandomState) else np.random.RandomState(rs)
        
        # Initialize parent LogisticRegression with all kwargs
        super().__init__(**kwargs)

    def fit(self, X, y, sample_weight=None):
        """
        Fit the logistic regression model and tune threshold.
        
        First fits standard logistic regression, then tunes the decision threshold
        to achieve the target PPV if specified.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Training data
        y : array-like of shape (n_samples,)
            Target values (0 or 1)
        sample_weight : array-like of shape (n_samples,), default=None
            Sample weights for fitting
            
        Returns
        -------
        self : object
            Fitted estimator
        """
        # Fit standard logistic regression
        super().fit(X, y, sample_weight)
        
        # Tune threshold if not manually specified
        if self.threshold is None:
            assert self.PPV_target is not None, 'Either threshold or PPV_target must be specified'
            self._tune_threshold_PPV(X, y)
        return self

    def predict(self, X):
        """
        Predict class labels using the tuned threshold.
        
        Instead of using the default 0.5 threshold, uses the custom threshold
        to make predictions based on predicted probabilities.
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Samples to predict
            
        Returns
        -------
        y_pred : array of shape (n_samples,)
            Predicted class labels (0 or 1)
        """
        assert self.threshold is not None, "Model not fitted or threshold not defined"
        # Use tuned threshold instead of default 0.5
        return (super().predict_proba(X)[:, 1] >= self.threshold).astype(int)

    def _tune_threshold_PPV(self, X, y):
        """
        Tune decision threshold to achieve target PPV with optional downsampling.
        
        This method:
        1. Optionally downsamples the negative class to achieve target prevalence
        2. Computes precision-recall curve on the (possibly downsampled) data
        3. Finds the threshold that achieves target PPV with maximum recall
        
        Parameters
        ----------
        X : array-like of shape (n_samples, n_features)
            Feature data for threshold tuning
        y : array-like of shape (n_samples,)
            True labels for threshold tuning
            
        Raises
        ------
        ValueError
            If not enough negative samples to achieve desired prevalence
        AssertionError
            If target PPV is too high to achieve
        """
        # Downsample negative class if target prevalence specified
        if self.downsample_prevalence is not None:
            y = np.asarray(y)
            pos_idx, neg_idx = np.where(y == 1)[0], np.where(y == 0)[0]
            n_pos = len(pos_idx)
            
            # Calculate required number of negatives for target prevalence
            # prevalence = n_pos / (n_pos + n_neg), so n_neg = n_pos * (1 - prev) / prev
            n_neg_downsampled = int(n_pos * (1 - self.downsample_prevalence) / self.downsample_prevalence)
            
            # Validate we have enough negatives to downsample
            if n_neg_downsampled > len(neg_idx):
                raise ValueError(f"Need {n_neg_downsampled} negatives but only have {len(neg_idx)}")
            
            # Downsample negatives and create new dataset with target prevalence
            downsampled_idx = np.concatenate([
                pos_idx, 
                self.random_state_.choice(neg_idx, n_neg_downsampled, replace=False)
            ])
            X, y = np.asarray(X)[downsampled_idx], y[downsampled_idx]
        
        # Compute precision-recall curve to find optimal threshold
        probs = super().predict_proba(X)[:, 1]
        prec, recall, thresh = precision_recall_curve(y, probs)

        # sklearn appends a sentinel point (prec=1.0, recall=0.0) with no corresponding
        # threshold entry, making len(prec) == len(thresh) + 1.  Exclude it so that
        # indexing into thresh cannot go out of bounds.
        prec_adj   = prec[:-1]
        recall_adj = recall[:-1]

        # Check whether the target PPV is achievable at any real threshold
        if not (prec_adj >= self.PPV_target).any():
            # Graceful fallback: use the threshold with the highest available precision
            self.threshold = float(thresh[np.argmax(prec_adj)])
            return

        # Among all operating points that meet the PPV target, select the one with
        # maximum recall (= lowest threshold), giving the most sensitive compliant model
        valid_mask = prec_adj >= self.PPV_target
        best_idx   = np.where(valid_mask & (recall_adj == recall_adj[valid_mask].max()))[0][0]
        self.threshold = float(thresh[best_idx])

The code above will be saved in to a file, Model.py.

Demonstration: Modified logistic regression

This notebook will demonstrate LR_w_thresh - a subclass of scikit-learn’s LogisticRegression that uses object-oriented inheritance to add:

  1. Automated threshold tuning - selects the decision threshold that maximizes recall while meeting a minimum Positive Predictive Value (PPV) requirement.
  2. Prevalence downsampling - calibrates the threshold at a user-specified prevalence, enabling deployment in populations with a different base rate.

The Problem with the Default Threshold

Logistic regression classifies a sample as positive when the estimated probability exceeds 0.5. For a well-calibrated model that cutoff already maximizes accuracy at any prevalence, so it is rarely the right operating point when:

  • The decision must satisfy an explicit constraint, such as a minimum PPV, rather than maximize overall accuracy, or
  • Precision and recall carry unequal costs.

In a clinical flagging task, Positive Predictive Value (PPV) is the fraction of flagged patients who truly have the condition:

PPV = TP / (TP + FP), where TP is true positives and FP false positives

A team triggering invasive follow-up may require PPV ≥ 0.80 - at most 1 in 5 alerts is a false positive. Meeting this requirement through manual inspection is error-prone and non-reproducible. LR_w_thresh automates it.

Why a Held-out Calibration Set?

The precision-recall curve used for threshold selection should be computed on data the model has never seen during weight fitting. If the same data are used for both (in-sample calibration), the model’s probabilities are slightly overfit - the selected threshold is too permissive and the PPV target may not be met on new data.

This notebook demonstrates both approaches side-by-side so the difference is concrete and measurable.

Python
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
from sklearn.datasets import make_classification
from sklearn.model_selection import train_test_split
from sklearn.linear_model import LogisticRegression
from sklearn.metrics import (
    precision_score, recall_score, f1_score,
    confusion_matrix, precision_recall_curve, roc_auc_score,
)
import warnings
warnings.filterwarnings("ignore")

# Custom LR subclass - located in the same directory as this notebook
from Model import LR_w_thresh

# ── Reproducibility ───────────────────────────────────────────────────────────
RANDOM_STATE = 42
np.random.seed(RANDOM_STATE)

# ── Plot style ────────────────────────────────────────────────────────────────
plt.rcParams.update({"figure.figsize": (12, 5), "font.size": 11})
sns.set_style("whitegrid")
COLORS = {"neg": "#4C72B0", "pos": "#DD8452"}

print("Imports OK")
Imports OK

Dataset: high-risk clinical outpatient cohort

We simulate a cohort of patients referred to a specialist clinic after a preliminary positive screen. Referral enriches the prevalence, since roughly 36 % of patients have the target condition (compared to ~5 % in the general population).

The clinical team requires that any algorithmic flag carries PPV ≥ 0.80 to justify follow-up, and explores a stricter PPV ≥ 0.90 threshold for higher-risk interventions.

Property Value
Total samples 30,000
Features 15 (8 informative, 4 redundant)
Positive prevalence ~36 %
Random label reassignment (flip_y) 6 %
Class separation moderate (class_sep = 1.3)

Three-way split

Subset Fraction Purpose
Fitting set 60 % Fit logistic regression weights
Calibration set 20 % Tune decision threshold (held-out - never seen by the model during fitting)
Test set 20 % Final performance evaluation

Using a separate calibration set prevents the in-sample bias discussed above.

Python
# ── Generate synthetic clinical cohort ───────────────────────────────────────
X, y = make_classification(
    n_samples     = 30_000,
    n_features    = 15,
    n_informative = 8,       # features with genuine predictive signal
    n_redundant   = 4,       # linear combinations of informative features
    n_repeated    = 0,
    n_classes     = 2,
    weights       = [0.65, 0.35],  # ~35 % positive prevalence
    flip_y        = 0.06,          # 6 % label noise - realistic clinical uncertainty
    class_sep     = 1.3,           # moderate signal strength
    random_state  = RANDOM_STATE,
)

# ── 60 / 20 / 20 split: fit / calibrate / test ────────────────────────────────
# Step 1: hold out 20 % for the final test evaluation
X_tmp, X_test, y_tmp, y_test = train_test_split(
    X, y, test_size=0.20, stratify=y, random_state=RANDOM_STATE
)
# Step 2: from the remaining 80 %, hold out 25 % (= 20 % of total) for calibration
X_fit, X_cal, y_fit, y_cal = train_test_split(
    X_tmp, y_tmp, test_size=0.25, stratify=y_tmp, random_state=RANDOM_STATE
)

# ── Report split sizes ────────────────────────────────────────────────────────
for name, y_s in [("Fitting set    ", y_fit), ("Calibration set", y_cal), ("Test set       ", y_test)]:
    print(f"{name}: {len(y_s):>6,} samples  "
          f"({int(y_s.sum()):,} positive = {y_s.mean()*100:.1f} %)")
Fitting set    : 18,000 samples  (6,467 positive = 35.9 %)
Calibration set:  6,000 samples  (2,155 positive = 35.9 %)
Test set       :  6,000 samples  (2,155 positive = 35.9 %)
Python
fig, axes = plt.subplots(1, 2, figsize=(13, 4))

# ── Left: overall class balance pie ──────────────────────────────────────────
n_neg_all = int((y == 0).sum())
n_pos_all = int(y.sum())
axes[0].pie(
    [n_neg_all, n_pos_all],
    labels=["Negative (0)", "Positive (1)"],
    autopct="%1.1f%%",
    colors=[COLORS["neg"], COLORS["pos"]],
    startangle=90,
    wedgeprops={"edgecolor": "white", "linewidth": 2},
)
axes[0].set_title("Overall Class Distribution", fontsize=13)

# ── Right: stacked bars showing each split ───────────────────────────────────
split_names = ["Fit\n(60 %)", "Calibrate\n(20 %)", "Test\n(20 %)"]
split_ys    = [y_fit, y_cal, y_test]
neg_counts  = [int((y_s == 0).sum()) for y_s in split_ys]
pos_counts  = [int(y_s.sum())        for y_s in split_ys]
x_pos = np.arange(3)

axes[1].bar(x_pos, neg_counts, label="Negative", color=COLORS["neg"], alpha=0.85)
axes[1].bar(x_pos, pos_counts, bottom=neg_counts, label="Positive",
            color=COLORS["pos"], alpha=0.85)
for xi, (n, p) in enumerate(zip(neg_counts, pos_counts)):
    axes[1].text(xi, n + p + 120, f"{n+p:,}", ha="center", fontsize=9)

axes[1].set_xticks(x_pos)
axes[1].set_xticklabels(split_names, fontsize=10)
axes[1].set_ylabel("Sample count")
axes[1].set_title("Three-way Split: Fit / Calibrate / Test", fontsize=13)
axes[1].legend()

plt.suptitle("Simulated High-Risk Clinical Cohort (~36 % Positive Prevalence)",
             fontsize=13, y=1.02)
plt.tight_layout()
plt.show()
Figure 4

Class balance and the three-way split

Pie chart of the simulated cohort’s class balance beside stacked bars of the fitting, calibration and test subsets
Figure 4. Two panels drawn by the code above from the simulated cohort of 30,000 patients, with a pie of the overall class balance on the left, and stacked bars of the fitting, calibration and test subsets on the right, each labeled with its total. Roughly 36 % of the cohort carries the target condition, well above the ~5 % of the general population, and the 60 / 20 / 20 split reserves a calibration subset the model never sees while its weights are fitted. The decision threshold is later chosen on that subset.

1. Baseline logistic regression (threshold = 0.50)

We first fit a standard LogisticRegression on the fitting set and evaluate it with the default 0.5 threshold. This establishes the performance baseline and confirms that the PPV target is not met by that baseline.

The model’s area under the receiver operating characteristic curve (ROC-AUC, about 0.83) confirms it has genuine discriminative ability. What needs fixing is the operating threshold. The cells in Sections 1 to 4 append to a list named results using a helper named evaluate, and Sections 5 and 6 read that list back. Neither is defined in any displayed block, so the notebook as printed cannot be run end to end without them.

Python
# ── Fit standard logistic regression on the fitting set ──────────────────────
lr_base = LogisticRegression(max_iter=1000, random_state=RANDOM_STATE)
lr_base.fit(X_fit, y_fit)

y_pred_base = lr_base.predict(X_test)  # threshold = 0.5 by default

auc = roc_auc_score(y_test, lr_base.predict_proba(X_test)[:, 1])
results.append(evaluate("1. Baseline LR  (thresh=0.50)", y_test, y_pred_base, 0.50))
r = results[-1]

print(f"ROC-AUC : {auc:.4f}  (model discriminative ability)")
print(f"PPV     : {r['PPV (Precision)']:.2%}  (clinical target: >= 80.00 %)")
print(f"Recall  : {r['Recall (Sensitivity)']:.2%}")
print(f"F1      : {r['F1 Score']:.2%}")
print(f"CM      : TP={r['TP']:,}  FP={r['FP']:,}  TN={r['TN']:,}  FN={r['FN']:,}")
ROC-AUC : 0.8302  (model discriminative ability)
PPV     : 74.40%  (clinical target: >= 80.00 %)
Recall  : 60.14%
F1      : 66.51%
CM      : TP=1,296  FP=446  TN=3,399  FN=859

Observation

At threshold = 0.50 the model achieves ~60 % recall but only ~74 % PPV. Approximately 1 in 4 flagged patients is a false positive - above the 1 in 5 acceptable rate. Reducing false positives requires raising the threshold. Doing so manually is impractical and non-reproducible.

LR_w_thresh solves this by scanning the precision-recall curve and selecting the lowest threshold that meets the PPV requirement when any threshold meets it (highest recall subject to precision ≥ target).

2. Threshold tuning: in-sample vs calibration-set calibration

LR_w_thresh.fit(X, y) fits the model weights and calibrates the threshold by computing the precision-recall curve on the same data X. When this data is also the fitting set, the calibration is in-sample - subtly biased. Because fit stores the tuned value in the threshold constructor parameter, a fitted instance keeps that threshold and does not re-tune when fit is called again.

_tune_threshold_PPV uses the already-fitted weights to score X_cal, then finds the optimal threshold on a precision-recall curve the weights never saw. That removes the optimism from fitted probabilities, though choosing the lowest compliant point on a finite curve still selects on noise, so aim a little above the floor. The weights do not change. Calling it from outside the class reaches past the public interface, as the class exposes no public method for calibrating on held-out data. In both the in-sample and held-out workflows, if no point on the precision-recall curve reaches the PPV target, the tuner falls back to the threshold with the highest available precision and raises no warning, so a missed target surfaces only in the final test-set check.

Python
# ── Fit model weights on the fitting set ────────────────────────────────────
# LR_w_thresh.fit() also performs in-sample calibration as a side effect.
# We record that threshold, then override it with the held-out calibration set.
lr_ppv80 = LR_w_thresh(PPV_target=0.80, max_iter=1000, random_state=RANDOM_STATE)
lr_ppv80.fit(X_fit, y_fit)

# ── In-sample calibration result (before override) ───────────────────────────
thresh_insample  = lr_ppv80.threshold
y_pred_insample  = lr_ppv80.predict(X_test)
ppv_insample     = precision_score(y_test, y_pred_insample, zero_division=0)
rec_insample     = recall_score(y_test, y_pred_insample, zero_division=0)

# ── Override threshold using the held-out calibration set ────────────────────
# Only the threshold changes; the fitted LR weights remain identical.
lr_ppv80._tune_threshold_PPV(X_cal, y_cal)

thresh_calset = lr_ppv80.threshold
y_pred_ppv80  = lr_ppv80.predict(X_test)
ppv_calset    = precision_score(y_test, y_pred_ppv80, zero_division=0)
rec_calset    = recall_score(y_test, y_pred_ppv80, zero_division=0)

# ── Side-by-side comparison ───────────────────────────────────────────────────
print(f"{'Calibration method':<22} {'Threshold':>10}  {'Test PPV':>9}  {'Recall':>8}  Result")
print("-" * 66)
miss = "(below 80 % target)" if ppv_insample < 0.80 else ""
print(f"{'In-sample':<22} {thresh_insample:>10.4f}  {ppv_insample:>9.2%}  "
      f"{rec_insample:>8.2%}  {miss}")
print(f"{'Calibration set':<22} {thresh_calset:>10.4f}  {ppv_calset:>9.2%}  "
      f"{rec_calset:>8.2%}  {'meets target' if ppv_calset >= 0.80 else ''}")

# Add the calibration-set model to the results table
results.append(evaluate("2. LR  PPV=0.80  (cal-set)", y_test, y_pred_ppv80, thresh_calset))
Calibration method      Threshold   Test PPV    Recall  Result
------------------------------------------------------------------
In-sample                  0.5905     79.82%    50.12%  (below 80 % target)
Calibration set            0.6010     80.90%    48.96%  meets target

Why the In-sample Threshold Misses the Target

The in-sample threshold (≈ 0.591) is selected because the model’s training-set probabilities suggest it achieves 80 % PPV there. But those probabilities are optimistic. The model has already fitted to those exact samples, so its confidence is slightly inflated.

When the same threshold is applied to the test set, the actual PPV is ~79.8 % - 0.2 percentage points short of the target.

The calibration-set threshold (≈ 0.601) is slightly higher (more conservative) because the precision-recall (PR) curve is computed on genuinely unseen data. On the test set it achieves ~80.9 % PPV, clearing the target on this split, though 0.9 percentage points is inside the sampling error of a precision estimated from about 1,300 flagged patients.

The gap is small here but tends to grow with smaller datasets and higher PPV targets. Using a held-out calibration set should be the default practice.

3. Higher PPV target (0.90): the precision–recall trade-off

Raising the PPV target to 0.90 illustrates the fundamental precision-recall trade-off. A stricter precision floor forces the threshold higher, reducing the number of positive predictions and, with it, recall.

This models an intervention with higher stakes - for example, a surgical procedure - where clinicians accept lower sensitivity in exchange for greater certainty.

Python
# ── PPV target = 0.90 using the calibration-set workflow ─────────────────────
lr_ppv90 = LR_w_thresh(PPV_target=0.90, max_iter=1000, random_state=RANDOM_STATE)
lr_ppv90.fit(X_fit, y_fit)
lr_ppv90._tune_threshold_PPV(X_cal, y_cal)  # threshold calibration on held-out data

y_pred_ppv90 = lr_ppv90.predict(X_test)
results.append(evaluate("3. LR  PPV=0.90  (cal-set)", y_test, y_pred_ppv90, lr_ppv90.threshold))
r = results[-1]

print(f"Threshold : {r['Threshold']:.4f}  (was {thresh_calset:.4f} for PPV=0.80)")
print(f"PPV       : {r['PPV (Precision)']:.2%}   target >= 90.00 %")
print(f"Recall    : {r['Recall (Sensitivity)']:.2%}   "
      f"(was {rec_calset:.2%} at PPV=0.80 - trade-off visible)")
print(f"F1        : {r['F1 Score']:.2%}")
print(f"CM        : TP={r['TP']:,}  FP={r['FP']:,}  TN={r['TN']:,}  FN={r['FN']:,}")
Threshold : 0.7861  (was 0.6010 for PPV=0.80)
PPV       : 91.10%   target >= 90.00 %
Recall    : 27.56%   (was 48.96% at PPV=0.80 - trade-off visible)
F1        : 42.32%
CM        : TP=594  FP=58  TN=3,787  FN=1,561

4. Prevalence downsampling: calibrating for a different deployment setting

downsample_prevalence allows the threshold to be calibrated at a simulated positive prevalence that differs from the training data.

Scenario: the same model will also be deployed in a specialist inpatient ward where the condition is more concentrated (~55 % prevalence, vs. ~36 % in the outpatient cohort). At higher prevalence, the same probability threshold yields higher PPV, so the threshold may be adjusted downward to maintain the same PPV target.

downsample_prevalence=0.55 instructs _tune_threshold_PPV to:

  1. Downsample the calibration-set negatives until positive prevalence = 55 %.
  2. Compute the PR curve on this higher-prevalence subset.
  3. Select the threshold where precision ≥ 0.80 at 55 % prevalence.

Expected behavior on the 36 % test set: the threshold calibrated at 55 % prevalence is lower (more permissive) than the one calibrated at 36 %, because precision is naturally higher when more positives are present. At actual test prevalence (36 %) this lower threshold yields higher recall but lower PPV - the correct cross-prevalence trade-off.

Python
# ── LR calibrated at 55 % simulated deployment prevalence ────────────────────
lr_ds = LR_w_thresh(
    PPV_target            = 0.80,
    downsample_prevalence = 0.55,   # simulate 55 % positive prevalence at calibration
    max_iter              = 1000,
    random_state          = RANDOM_STATE,
)
lr_ds.fit(X_fit, y_fit)
# Calibrate on the held-out cal set; downsampling is applied within _tune_threshold_PPV
lr_ds._tune_threshold_PPV(X_cal, y_cal)

print(f"Threshold (calibrated at 55 % prevalence) : {lr_ds.threshold:.4f}")
print(f"Threshold (calibrated at 36 % prevalence) : {thresh_calset:.4f}  [Model 2]")
print(f"=> Lower threshold reflects the higher-prevalence calibration context")

y_pred_ds = lr_ds.predict(X_test)
results.append(evaluate("4. LR  PPV=0.80, DS=0.55  (cal-set)", y_test, y_pred_ds, lr_ds.threshold))
r = results[-1]
print(f"\nOn ~36 % prevalence test set:")
print(f"PPV    : {r['PPV (Precision)']:.2%}   "
      "(target was 80 % at 55 % prevalence; expected to be lower here)")
print(f"Recall : {r['Recall (Sensitivity)']:.2%}   "
      f"(higher than Model 2's {rec_calset:.2%}, as expected from the lower threshold)")
print(f"CM     : TP={r['TP']:,}  FP={r['FP']:,}  TN={r['TN']:,}  FN={r['FN']:,}")
Threshold (calibrated at 55 % prevalence) : 0.3796
Threshold (calibrated at 36 % prevalence) : 0.6010  [Model 2]
=> Lower threshold reflects the higher-prevalence calibration context

On ~36 % prevalence test set:
PPV    : 65.85%   (target was 80 % at 55 % prevalence; expected to be lower here)
Recall : 72.02%   (higher than Model 2's 48.96%, as expected from the lower threshold)
CM     : TP=1,552  FP=805  TN=3,040  FN=603

5. Results comparison

The table and plots below compare all four models on the held-out test set.

# Model Key feature Expected test PPV
1 Baseline (0.50) Default threshold ~74 % - below both targets
2 PPV=0.80 cal-set Threshold tuned at 36 % prevalence ≥ 80 %
3 PPV=0.90 cal-set Stricter threshold ≥ 90 %
4 DS=0.55 cal-set Threshold tuned at 55 % prevalence < 80 % at 36 % test prev.

Model 4’s PPV being below 80 % on the test set is expected behavior: the threshold was optimized for a 55 % prevalence deployment and is being read at 36 %.

Python
display_cols = ["Model", "Threshold", "PPV (Precision)", "Recall (Sensitivity)", "F1 Score"]
results_df = pd.DataFrame(results)[display_cols].set_index("Model")

styled = (
    results_df.style
    .format("{:.4f}")
    .background_gradient(subset=["PPV (Precision)"],      cmap="Greens", vmin=0.50, vmax=1.0)
    .background_gradient(subset=["Recall (Sensitivity)"], cmap="Blues",  vmin=0.00, vmax=1.0)
    .set_caption("Model Performance on ~36 % Prevalence Test Set")
)
display(styled)
Table 1

Test-set performance of the four models

Table 1. Threshold, precision, recall and F1 for each model on the held-out test set, rendered by the pandas Styler above with green shading on precision and blue on recall. At the 0.500 baseline, precision reaches 0.744. That is short of the 0.80 clinical target. Tuning on the calibration set moves the threshold to 0.601: precision climbs to 0.809, recall drops from 0.601 to 0.490. Push for 0.90 and the threshold goes to 0.786, recall to 0.276. The last row was calibrated for a 55 % prevalence setting, so its 0.659 precision at 36 % prevalence is exactly what should happen.
 ThresholdPPV (Precision)Recall (Sensitivity)F1 Score
Model    
1. Baseline LR (thresh=0.50)0.5000.7440.6010.665
2. LR PPV=0.80 (cal-set)0.6010.8090.4900.610
3. LR PPV=0.90 (cal-set)0.7860.9110.2760.423
4. LR PPV=0.80, DS=0.55 (cal-set)0.3800.6590.7200.688
Python
fig, axes = plt.subplots(1, 2, figsize=(15, 5))

# ── LEFT: Precision-Recall curve with all operating points ────────────────────
# All models share the same LR weights (fitted on X_fit); one PR curve covers all.
probs_test = lr_ppv80.predict_proba(X_test)[:, 1]
prec_c, rec_c, _ = precision_recall_curve(y_test, probs_test)

axes[0].plot(rec_c, prec_c, color="steelblue", lw=2.5, label="PR curve", zorder=1)

# PPV target reference lines
axes[0].axhline(0.80, color=COLORS["pos"], linestyle="--", lw=1.5, alpha=0.85,
                label="PPV = 0.80 target")
axes[0].axhline(0.90, color="#C44E52",     linestyle="--", lw=1.5, alpha=0.85,
                label="PPV = 0.90 target")

# Operating points for each model - plotted in order of increasing threshold
op_points = [
    # (recall, PPV, color, marker, label)
    (results_df.loc["4. LR  PPV=0.80, DS=0.55  (cal-set)", "Recall (Sensitivity)"],
     results_df.loc["4. LR  PPV=0.80, DS=0.55  (cal-set)", "PPV (Precision)"],
     "#55A868", "^", "DS=0.55 (lower threshold)"),
    (results_df.loc["1. Baseline LR  (thresh=0.50)", "Recall (Sensitivity)"],
     results_df.loc["1. Baseline LR  (thresh=0.50)", "PPV (Precision)"],
     "gray",    "D", "Baseline (0.50)"),
    (rec_insample, ppv_insample,
     "#FFB347", "x", "PPV=0.80 in-sample (misses target)"),
    (results_df.loc["2. LR  PPV=0.80  (cal-set)", "Recall (Sensitivity)"],
     results_df.loc["2. LR  PPV=0.80  (cal-set)", "PPV (Precision)"],
     COLORS["pos"], "o", "PPV=0.80 cal-set"),
    (results_df.loc["3. LR  PPV=0.90  (cal-set)", "Recall (Sensitivity)"],
     results_df.loc["3. LR  PPV=0.90  (cal-set)", "PPV (Precision)"],
     "#C44E52", "s", "PPV=0.90 cal-set"),
]
for rec_pt, ppv_pt, color, marker, label in op_points:
    axes[0].scatter(rec_pt, ppv_pt, color=color, marker=marker,
                    s=180 if marker != "x" else 200,
                    zorder=5, label=label, edgecolors="black", linewidths=0.8)

axes[0].set_xlabel("Recall (Sensitivity)", fontsize=12)
axes[0].set_ylabel("Precision (PPV)",       fontsize=12)
axes[0].set_title("Precision-Recall Curve\nwith Model Operating Points", fontsize=13)
axes[0].set_xlim([0, 1.02]); axes[0].set_ylim([0, 1.05])
axes[0].legend(fontsize=8, loc="lower left")

# ── RIGHT: grouped bar chart - main four models ───────────────────────────────
x      = np.arange(len(results_df))
width  = 0.28
labels_short = ["Baseline\n(0.50)", "PPV=0.80\ncal-set", "PPV=0.90\ncal-set",
                "DS=0.55\ncal-set"]
ppv_v    = results_df["PPV (Precision)"].values
recall_v = results_df["Recall (Sensitivity)"].values
f1_v     = results_df["F1 Score"].values

b1 = axes[1].bar(x - width, ppv_v,    width, label="PPV",    color="#4C72B0", alpha=0.85)
b2 = axes[1].bar(x,          recall_v, width, label="Recall", color=COLORS["pos"], alpha=0.85)
b3 = axes[1].bar(x + width,  f1_v,     width, label="F1",     color="#55A868", alpha=0.85)

# PPV target reference lines
axes[1].axhline(0.80, color="#4C72B0", linestyle="--", lw=1.3, alpha=0.7, label="PPV 0.80 target")
axes[1].axhline(0.90, color="#4C72B0", linestyle=":",  lw=1.3, alpha=0.7, label="PPV 0.90 target")

axes[1].set_xticks(x); axes[1].set_xticklabels(labels_short, fontsize=9)
axes[1].set_ylim([0, 1.15])
axes[1].set_ylabel("Score", fontsize=12)
axes[1].set_title("PPV / Recall / F1 by Model", fontsize=13)
axes[1].legend(fontsize=9, ncol=2)

for bars in [b1, b2, b3]:
    for bar in bars:
        h = bar.get_height()
        if h > 0.02:
            axes[1].text(bar.get_x() + bar.get_width() / 2, h + 0.012,
                         f"{h:.2f}", ha="center", va="bottom", fontsize=7.5)

plt.suptitle("Model Comparison: Threshold Tuning and Prevalence Downsampling",
             fontsize=14, y=1.01)
plt.tight_layout()
plt.show()
Figure 5

Operating points on the precision-recall curve

Precision-recall curve for the test set with each model’s operating point marked against the 0.80 and 0.90 targets, beside grouped bars of precision, recall and F1
Figure 5. The left panel draws a single precision-recall curve for the test set, since all four models share the same fitted weights, and marks each operating point against the 0.80 and 0.90 target lines. The right panel repeats precision, recall and F1 as grouped bars with the same references. Every marker lies on that one curve, so choosing a threshold slides the operating point along it and leaves the model where it is. On this test split the in-sample point falls under the 0.80 line at 79.82 % precision while the calibration-set point clears it at 80.90 %.

6. Explicit PPV target verification

The cell below confirms whether each model’s PPV target is met on the test set, and explains the expected behavior of the downsampled model.

Python
# Each model's intended PPV target (None = no target was set)
targets = {
    "1. Baseline LR  (thresh=0.50)"       : None,   # no PPV target
    "2. LR  PPV=0.80  (cal-set)"          : 0.80,
    "3. LR  PPV=0.90  (cal-set)"          : 0.90,
    "4. LR  PPV=0.80, DS=0.55  (cal-set)" : None,   # target was at 55 % prevalence
}

print(f"{'Model':<47} {'Test PPV':>9}  {'Target':>8}  Status")
print("-" * 76)
all_named_pass = True
for mname, target in targets.items():
    ppv = results_df.loc[mname, "PPV (Precision)"]
    if target is None:
        status = "(no target on test-set prevalence)"
    elif ppv >= target:
        status = "PASS"
    else:
        all_named_pass = False
        status = f"FAIL  (gap = {ppv - target:+.4f})"
    t_str = f"{target:.2f}" if target else "  -- "
    print(f"{mname:<47} {ppv:>9.4f}  {t_str:>8}  {status}")

print()
if all_named_pass:
    print("All PPV targets MET on the held-out test set.")
Model                                            Test PPV    Target  Status
----------------------------------------------------------------------------
1. Baseline LR  (thresh=0.50)                      0.7440       --   (no target on test-set prevalence)
2. LR  PPV=0.80  (cal-set)                         0.8090      0.80  PASS
3. LR  PPV=0.90  (cal-set)                         0.9110      0.90  PASS
4. LR  PPV=0.80, DS=0.55  (cal-set)                0.6585       --   (no target on test-set prevalence)

All PPV targets MET on the held-out test set.

7. Threshold sensitivity analysis

Sweeping the decision threshold from 0 to 1 shows the continuous precision-recall landscape. Vertical dashed lines mark each model’s operating point, and the horizontal lines show the PPV targets. This chart makes the trade-offs quantitative and visible.

Python
fig, ax = plt.subplots(figsize=(13, 5))

# precision_recall_curve returns one more prec/recall value than threshold values
# (sentinel: prec[-1]=1.0, recall[-1]=0.0). Exclude it for the threshold sweep.
prec_c, rec_c, thresh_c = precision_recall_curve(y_test, probs_test)
prec_plot  = prec_c[:-1]
rec_plot   = rec_c[:-1]

ax.plot(thresh_c, prec_plot, color="#4C72B0", lw=2.2, label="PPV (Precision)")
ax.plot(thresh_c, rec_plot,  color=COLORS["pos"], lw=2.2, label="Recall (Sensitivity)")

# Mark each operating threshold with a vertical line and scatter point
op_thresholds = [
    (lr_ds.threshold,    "#55A868",     "^", f"DS=0.55  (t={lr_ds.threshold:.3f})"),
    (0.50,               "gray",        "D", f"Baseline (t=0.50)"),
    (thresh_insample,    "#FFB347",     "x", f"PPV=0.80 in-sample (t={thresh_insample:.3f})"),
    (thresh_calset,      COLORS["pos"], "o", f"PPV=0.80 cal-set (t={thresh_calset:.3f})"),
    (lr_ppv90.threshold, "#C44E52",     "s", f"PPV=0.90 cal-set (t={lr_ppv90.threshold:.3f})"),
]
for t, color, marker, label in op_thresholds:
    ax.axvline(t, color=color, linestyle="--", lw=1.2, alpha=0.65)
    idx = min(np.searchsorted(thresh_c, t), len(thresh_c) - 1)
    # Plot marker on both the PPV curve and the recall curve
    for y_val in [prec_plot[idx], rec_plot[idx]]:
        ax.scatter(thresh_c[idx], y_val, color=color, marker=marker,
                   s=150 if marker != "x" else 180, zorder=5,
                   edgecolors="black", linewidths=0.8)
    ax.scatter([], [], color=color, marker=marker, s=100,
               edgecolors="black", linewidths=0.8, label=label)  # legend proxy

# PPV target reference lines
ax.axhline(0.80, color="#4C72B0", linestyle=":",  lw=1.3, alpha=0.65, label="PPV = 0.80 target")
ax.axhline(0.90, color="#4C72B0", linestyle="-.", lw=1.3, alpha=0.65, label="PPV = 0.90 target")

ax.set_xlabel("Decision Threshold", fontsize=12)
ax.set_ylabel("Score",              fontsize=12)
ax.set_title("PPV and Recall as a Function of the Decision Threshold", fontsize=13)
ax.set_xlim([0, 1]); ax.set_ylim([0, 1.05])
ax.legend(fontsize=8.5, loc="center left", bbox_to_anchor=(1.01, 0.5))
plt.tight_layout()
plt.show()
Figure 6

PPV and recall across the threshold sweep

Precision and recall plotted against the decision threshold, with vertical lines at each model’s threshold and horizontal lines at the two targets
Figure 6. Precision and recall on the test set at every threshold returned by the precision-recall curve, with a vertical dashed line at each model’s operating threshold and horizontal references at the two targets. Precision climbs gradually across the sweep while recall falls away steeply, which is the trade the stricter target makes. Moving from 0.601 to 0.786 raises precision from 80.90 % to 91.10 % but drops recall from 48.96 % to 27.56 %. The in-sample line at 0.591 sits just left of the calibration-set line at 0.601, and that small gap separates 79.82 % precision from 80.90 %.

8. Scikit-learn compatibility

LR_w_thresh inherits from LogisticRegression and therefore keeps the interface of a scikit-learn estimator. It works inside cross_val_score and Pipeline with no extra code. It does not, however, meet the full estimator contract of Figure 3. The constructor takes a keyword-argument catch-all rather than explicit keyword arguments, so get_params reports only the three custom parameters and clone does not carry parent arguments such as max_iter and random_state, which is the failure Figure 3 warns about. It also departs from two other rules in that figure, because fit writes the tuned cutoff back into the threshold parameter instead of exposing it as a fitted threshold_ attribute, which leaves refitting with the stored cutoff instead of re-tuning. The constructor also derives random_state_ when it should only store its arguments. A stricter version would leave threshold untouched, store the tuned value as threshold_, and build the random state inside fit.

Note on cross-validation: sklearn’s CV framework calls fit() on each fold’s training subset, which performs in-sample calibration. This is equivalent to the standard usage of LR_w_thresh. A custom CV loop would be needed to implement the calibration-set approach within CV. The variance seen across folds is sampling variability, and the in-sample-calibration gap observed on the single split in Section 2 is something else again. The mean PPV of 0.7985 sitting just below the 0.80 target is the direction that gap predicts.

Python
from sklearn.model_selection import cross_val_score
from sklearn.pipeline import Pipeline
from sklearn.preprocessing import StandardScaler

# ── 5-fold cross-validated PPV on the fitting set ────────────────────────────
# Each fold calls fit() on ~80 % of X_fit and scores on the remaining 20 %.
# The threshold is calibrated in-sample within each fold.
cv_scores = cross_val_score(
    estimator = LR_w_thresh(PPV_target=0.80, max_iter=1000, random_state=RANDOM_STATE),
    X=X_fit, y=y_fit,
    cv=5,
    scoring="precision",   # precision == PPV for binary classification
    n_jobs=-1,
)
print("5-Fold Cross-Validated PPV  (PPV target = 0.80, in-sample calibration)")
print(f"  Per-fold : {np.round(cv_scores, 4)}")
print(f"  Mean PPV : {cv_scores.mean():.4f}  (target = 0.80)")
print(f"  Std      : {cv_scores.std():.4f}")
print()
print("  Variance across folds is sampling variability, not evidence of the calibration bias")
print("  described in Section 2 - the mean PPV below target is the direction that bias predicts.")

# ── LR_w_thresh inside a Pipeline with StandardScaler ────────────────────────
# The pipeline fits the scaler and model together; _tune_threshold_PPV then
# receives the scaled calibration-set features in the real workflow.
pipe = Pipeline([
    ("scaler", StandardScaler()),
    ("clf",    LR_w_thresh(PPV_target=0.80, max_iter=1000, random_state=RANDOM_STATE)),
])
pipe.fit(X_fit, y_fit)
pipe_ppv = precision_score(y_test, pipe.predict(X_test), zero_division=0)
print(f"\nPipeline (StandardScaler + LR_w_thresh)  PPV on test set: {pipe_ppv:.4f}")
5-Fold Cross-Validated PPV  (PPV target = 0.80, in-sample calibration)
  Per-fold : [0.8245 0.7668 0.8025 0.8081 0.7904]
  Mean PPV : 0.7985  (target = 0.80)
  Std      : 0.0193

  Variance across folds is sampling variability, not evidence of the calibration bias
  described in Section 2 - the mean PPV below target is the direction that bias predicts.

Pipeline (StandardScaler + LR_w_thresh)  PPV on test set: 0.7981

Summary of results

Goal Outcome
Default threshold fails the PPV target Confirmed: ~74 % PPV at threshold=0.50
In-sample calibration is optimistic in direction Observed: missed the 80 % target by ~0.2 pp on one split
Calibration-set tuning met PPV=0.80 on this split Observed: ~80.9 % on test set
Calibration-set tuning met PPV=0.90 on this split Observed: ~91.1 % on test set
Precision-recall trade-off visible Confirmed: recall drops from ~49 % to ~28 %
Downsampling shifts threshold as expected Confirmed: lower threshold at 55 % prevalence
Scikit-learn Pipeline and CV compatible Confirmed

Key design principles

  1. Calibration data should be held out from model fitting. In-sample calibration is optimistic and may fail the stated PPV target on new data. A three-way fit / calibrate / test split is the recommended practice.

  2. Threshold tuning separates operating decision from model training. The same fitted weights support multiple thresholds (e.g. 80 % and 90 % PPV) suited to different clinical contexts.

  3. Prevalence downsampling is a deployment-context tool. It calibrates the threshold for a different base rate. The resulting PPV at the original prevalence will differ from the stated target, which is the correct and expected behavior.

  4. Inheritance keeps the extension transparent and auditable. The threshold selection logic lives in _tune_threshold_PPV. The rest is standard LogisticRegression, supporting reproducibility and scikit-learn interoperability.

Summary

Key Takeaways

The threshold comes after the fit

Logistic regression learns coefficients; 0.5 is a convention layered on top, and nothing in the fit optimizes it. One set of weights served a 0.80 target at threshold 0.601 and a 0.90 target at 0.786.

Tune the threshold on data the weights never saw

Calibrating on the fitting rows chose 0.591 and reached 79.82 % precision on test, under the 0.80 target. The held-out calibration set chose 0.601 and reached 80.90 %. One split shows the direction of the gap and leaves its size uncertain.

The contract extends beyond fit and predict

A Pipeline runs the subclass because fit and predict are all it needs. get_params, clone and GridSearchCV ask more. Every parameter must be an explicit keyword argument, stored unchanged under its own name. Parameters behind **kwargs are invisible to them.

A constructor only stores its arguments

clone calls the constructor again with what get_params reported, so a value derived inside the constructor is rebuilt rather than carried across. Learned state belongs in fit, marked by a trailing underscore, and fit should not overwrite its own parameters.

Precision and recall move against each other

Raising the target from 0.80 to 0.90 pushed the threshold from 0.601 to 0.786 and cut recall from 48.96 % to 27.56 %. Which point to stand on is a judgment about the cost of a false alarm against the cost of a missed case.

Subclassing is one option among several

scikit-learn 1.5 added TunedThresholdClassifierCV and FixedThresholdClassifier, which wrap a classifier and tune or fix its decision threshold. Subclass when behavior inside the estimator must change. Compose when it only needs wrapping.

Data & License

Data

No third-party dataset. The examples run on synthetic data generated with scikit-learn’s make_classification, so no external data license applies.

Article

© 2025 Philip Sarajlic. All rights reserved for the article’s original text and figures.

Code

Code examples in this article are licensed under the Common Public Attribution License Version 1.0 (CPAL-1.0), an OSI-approved copyleft license based on the Mozilla Public License 1.1. Initial Developer: Philip Sarajlic.

Attribution required by CPAL Exhibit B: © 2025 Philip Sarajlic · “Based on code by Philip Sarajlic” · philipsarajlic.com · no graphic image. This attribution must be displayed in Larger Works.

Modifications must be released in Source Code form under CPAL-1.0. Making the code usable by anyone other than you over a network is External Deployment under the license and is treated as distribution, so the Source Code must be made available to those users.

Full text: opensource.org/license/cpal-1-0 (SPDX identifier CPAL-1.0)

Scroll to Top

Free diagnostic

Would your model hold up to an external review?

Answer 39 questions in about nine minutes and get a clear picture of where your model stands.

You’ll receive a readiness score, a breakdown across 15 areas, your biggest evidence gaps, and the five questions an external reviewer would be most likely to ask first.

The assessment draws on guidance from:

  • TRIPOD+AI
  • PROBAST+AI
  • FDA GMLP
  • NIST AI RMF
  • SR 11-7

We’ll send you one email with your link, and your results when you finish. Your email address is carried through when you complete the assessment, so you won’t need to enter it again.

We don’t ask for your data, and there are no free-text fields in the assessment. See how we use your email address.