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.
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 selfClass 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 parameterPPV_targetconstructor parameterdownsample_prevalenceconstructor parameter
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.
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.
_tune_threshold_PPV(X, y)runs in the childThe 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.
Illustrative data, 22 positive and 26 negative cases.
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.
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())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
ExtendingEstimators
How object-oriented inheritance extends scikit-learn's LogisticRegression with automatic threshold tuning and prevalence downsampling, while staying pipeline compatible.
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.
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.
"""
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:
- Automated threshold tuning - selects the decision threshold that maximizes recall while meeting a minimum Positive Predictive Value (PPV) requirement.
- 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.
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.
# ── 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 %)
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()Class balance and the three-way split

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.
# ── 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.
# ── 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.
# ── 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:
- Downsample the calibration-set negatives until positive prevalence = 55 %.
- Compute the PR curve on this higher-prevalence subset.
- 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.
# ── 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 %.
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)Test-set performance of the four models
| Threshold | PPV (Precision) | Recall (Sensitivity) | F1 Score | |
|---|---|---|---|---|
| Model | ||||
| 1. Baseline LR (thresh=0.50) | 0.500 | 0.744 | 0.601 | 0.665 |
| 2. LR PPV=0.80 (cal-set) | 0.601 | 0.809 | 0.490 | 0.610 |
| 3. LR PPV=0.90 (cal-set) | 0.786 | 0.911 | 0.276 | 0.423 |
| 4. LR PPV=0.80, DS=0.55 (cal-set) | 0.380 | 0.659 | 0.720 | 0.688 |
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()Operating points on the precision-recall curve

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.
# 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.
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()PPV and recall across the threshold sweep

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.
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
-
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.
-
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.
-
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.
-
Inheritance keeps the extension transparent and auditable. The threshold selection logic lives in
_tune_threshold_PPV. The rest is standardLogisticRegression, supporting reproducibility and scikit-learn interoperability.
Key Takeaways
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.
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.
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.
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.
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.
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
No third-party dataset. The examples run on synthetic data generated with scikit-learn’s make_classification, so no external data license applies.
© 2025 Philip Sarajlic. All rights reserved for the article’s original text and figures.
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)


















