.. DO NOT EDIT. .. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. .. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: .. "auto_examples/pitfalls_and_solutions/plot_skd004_high_class_imbalance.py" .. LINE NUMBERS ARE GIVEN BELOW. .. only:: html .. note:: :class: sphx-glr-download-link-note :ref:`Go to the end ` to download the full example code. .. rst-class:: sphx-glr-example-title .. _sphx_glr_auto_examples_pitfalls_and_solutions_plot_skd004_high_class_imbalance.py: .. _example_skd004_high_class_imbalance: SKD004 - High class imbalance ============================= :ref:`SKD004 ` flags a binary classification task when the majority class exceeds 80 % of rows. Accuracy can look high while the minority class is ignored as a default. This notebook is mostly about how to work with that imbalance once the check fires: we do not try to make SKD004 disappear, because natural prevalence is often the right thing to keep. What we do instead (see also :ref:`automated_checks`): - report absolute counts as well as percentages, - evaluate ranking and calibration (ROC AUC, log-loss) before trusting thresholded precision / recall, - tune the decision threshold under an explicit precision / recall or cost constraint (for example with :class:`~sklearn.model_selection.TunedThresholdClassifierCV`), - avoid ``class_weight`` and resampling when calibrated probabilities matter, - correct for prevalence shift if you collect minority-only data. See also: https://probabl-ai.github.io/calibration-cost-sensitive-learning/content/notebooks/imbalanced_classification.html We use Covertype forest types 2 (majority) vs 5 (minority) on an 8,000-row stratified subsample. The goal is to keep natural prevalence, judge probability quality first, then choose a cut-off that matches the precision / recall trade-off you care about. .. GENERATED FROM PYTHON SOURCE LINES 35-42 Load Covertype (types 2 vs 5) ============================= Types 2 vs 5 give a natural imbalance (type 2 is the majority class). We keep minority type 5 as the positive class and draw an 8,000-row stratified subsample so the gallery stays fast while absolute minority counts remain large enough to learn from. .. GENERATED FROM PYTHON SOURCE LINES 42-60 .. code-block:: Python import numpy as np from sklearn.datasets import fetch_covtype from sklearn.model_selection import train_test_split df = fetch_covtype(as_frame=True).frame pair = df.query("Cover_Type.isin([2, 5])") y_full = (pair["Cover_Type"] == 5).astype(int).rename("is_type_5") X_full = pair.drop(columns=["Cover_Type"]) X, _, y, _ = train_test_split( X_full, y_full, train_size=8_000, stratify=y_full, random_state=42, ) .. GENERATED FROM PYTHON SOURCE LINES 61-63 .. code-block:: Python y.value_counts(normalize=True).round(4) .. rst-class:: sphx-glr-script-out .. code-block:: none is_type_5 0 0.9676 1 0.0324 Name: proportion, dtype: float64 .. GENERATED FROM PYTHON SOURCE LINES 64-66 .. code-block:: Python y.value_counts() .. rst-class:: sphx-glr-script-out .. code-block:: none is_type_5 0 7741 1 259 Name: count, dtype: int64 .. GENERATED FROM PYTHON SOURCE LINES 67-68 Inspect the feature matrix with :class:`~skrub.TableReport`. .. GENERATED FROM PYTHON SOURCE LINES 68-73 .. code-block:: Python from skrub import TableReport TableReport(X) .. raw:: html

Please enable javascript

The skrub table reports need javascript to display correctly. If you are displaying a report in a Jupyter notebook and you see this message, you may need to re-execute the cell or to trust the notebook (button on the top right or "File > Trust notebook").



.. GENERATED FROM PYTHON SOURCE LINES 74-76 The binary target marks type-5 stands; the majority class exceeds 80 % of rows, so SKD004 will fire. .. GENERATED FROM PYTHON SOURCE LINES 76-79 .. code-block:: Python TableReport(y) .. raw:: html

Please enable javascript

The skrub table reports need javascript to display correctly. If you are displaying a report in a Jupyter notebook and you see this message, you may need to re-execute the cell or to trust the notebook (button on the top right or "File > Trust notebook").



.. GENERATED FROM PYTHON SOURCE LINES 80-86 Trigger SKD004 - default classifier on imbalanced labels ======================================================== A default gradient boosting classifier does not change label counts. The check cares about the class mix in the data, not about whether we reweighted the loss. .. GENERATED FROM PYTHON SOURCE LINES 86-102 .. code-block:: Python from sklearn.ensemble import HistGradientBoostingClassifier from skore import TrainTestSplit, evaluate splitter = TrainTestSplit(test_size=0.2, random_state=42, stratify=y) classifier = HistGradientBoostingClassifier(random_state=42) report = evaluate( classifier, X=X, y=y, pos_label=1, splitter=splitter, ) report .. raw:: html
HistGradientBoostingClassifier(random_state=42)
In a Jupyter environment, please rerun this cell to show the HTML representation or trust the notebook.
On GitHub, the HTML representation is unable to render, please try loading this page with nbviewer.org.

Please enable javascript

The skrub table reports need javascript to display correctly. If you are displaying a report in a Jupyter notebook and you see this message, you may need to re-execute the cell or to trust the notebook (button on the top right or "File > Trust notebook").



.. GENERATED FROM PYTHON SOURCE LINES 103-105 SKD004 should fire: the majority class exceeds 80 % of rows. Ignore SKD008 to avoid correlated-feature warnings from Covertype's constant soil one-hots. .. GENERATED FROM PYTHON SOURCE LINES 105-108 .. code-block:: Python report.checks.summarize(fast_mode=True, ignore=["SKD008"]) .. raw:: html


.. GENERATED FROM PYTHON SOURCE LINES 109-117 Accuracy and F1 are poor defaults under imbalance ================================================= Accuracy can be inflated by nearly always predicting the majority class. F1 averages precision and recall into one number and hides which side of the trade-off you care about, so we do not use it here. At the default probability cut-off of 0.5, minority recall is often weak because rare events receive small predicted probabilities. .. GENERATED FROM PYTHON SOURCE LINES 117-123 .. code-block:: Python report.metrics.summarize( metric=["accuracy", "precision", "recall", "roc_auc", "log_loss"], data_source="both", ).frame() .. raw:: html
HistGradientBoostingClassifier (train) HistGradientBoostingClassifier (test)
metric
accuracy 1.000000 0.970625
precision 1.000000 0.608696
recall 1.000000 0.269231
roc_auc 1.000000 0.909586
log_loss 0.005539 0.112095


.. GENERATED FROM PYTHON SOURCE LINES 124-127 The stratified hold-out still has only a few dozen type-5 rows against about 1,500 majority rows. Most of those scarce positives are predicted as majority, so minority recall is low while accuracy stays high. .. GENERATED FROM PYTHON SOURCE LINES 127-130 .. code-block:: Python report.metrics.confusion_matrix().plot() .. image-sg:: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_001.png :alt: Confusion Matrix Data source: Test set :srcset: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_001.png :class: sphx-glr-single-img .. rst-class:: sphx-glr-script-out .. code-block:: none
.. GENERATED FROM PYTHON SOURCE LINES 131-154 Check ranking and calibration first =================================== Before touching thresholds, ask whether probabilities are any good: - ROC AUC asks whether positives tend to get higher scores than negatives (threshold-free ranking), - log-loss penalizes confident wrong probabilities, - a calibration curve asks whether predicted probabilities match observed frequencies. On the calibration plot, bins of predicted probability are compared to the fraction of true positives in each bin. A useful curve hugs the diagonal: when the model says "20 %", about 20 % of those rows really are positive. Points above the diagonal mean under-confidence (events happen more often than predicted); points below mean over-confidence (the model is too sure). With a rare class, almost all mass sits at low probabilities, so the curve often only appears on the left of the plot; that is expected, not a plotting bug. If ranking and calibration look reasonable, the model may already be useful; the default 0.5 cut-off is simply the wrong operating point for a rare class. The next section shows how ``class_weight="balanced"`` can push the curve below the diagonal by inflating minority probabilities. .. GENERATED FROM PYTHON SOURCE LINES 154-160 .. code-block:: Python report.metrics.summarize( metric=["roc_auc", "log_loss"], data_source="test", ).frame() .. rst-class:: sphx-glr-script-out .. code-block:: none metric roc_auc 0.909586 log_loss 0.112095 Name: HistGradientBoostingClassifier, dtype: float64 .. GENERATED FROM PYTHON SOURCE LINES 161-163 .. code-block:: Python report.inspection.calibration_curve(data_source="test", n_bins=10).plot() .. image-sg:: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_002.png :alt: Calibration Curve of HistGradientBoostingClassifier Positive label: 1 :srcset: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_002.png :class: sphx-glr-single-img .. rst-class:: sphx-glr-script-out .. code-block:: none
.. GENERATED FROM PYTHON SOURCE LINES 164-177 Class weights as a cautionary comparison ======================================== A common reflex is ``class_weight="balanced"``. Rebalancing with weights is equivalent in spirit to resampling methods such as SMOTE or random oversampling / undersampling: they change the effective class mix and will suffer from the same issues. That often improves precision / recall at 0.5 because it inflates minority probabilities, but it typically breaks calibration: predicted probabilities run ahead of observed rates, so the curve drifts below the diagonal (over-confidence on the originally rare class). If you later recalibrate, the thresholded gains often disappear. We show the comparison, then leave weights behind when calibrated probabilities matter. .. GENERATED FROM PYTHON SOURCE LINES 177-196 .. code-block:: Python from skore import compare report_weighted = evaluate( HistGradientBoostingClassifier(class_weight="balanced", random_state=42), X=X, y=y, pos_label=1, splitter=splitter, ) comparison_weights = compare( {"default": report, "class_weight_balanced": report_weighted} ) comparison_weights.metrics.summarize( metric=["precision", "recall", "roc_auc", "log_loss"], data_source="test", ).frame() .. raw:: html
estimator default class_weight_balanced
metric
precision 0.608696 0.428571
recall 0.269231 0.461538
roc_auc 0.909586 0.911225
log_loss 0.112095 0.111834


.. GENERATED FROM PYTHON SOURCE LINES 197-199 After reweighting, compare this curve to the default one: points tend to sit further below the diagonal (over-confident on the rare class). .. GENERATED FROM PYTHON SOURCE LINES 199-202 .. code-block:: Python report_weighted.inspection.calibration_curve(data_source="test", n_bins=10).plot() .. image-sg:: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_003.png :alt: Calibration Curve of HistGradientBoostingClassifier Positive label: 1 :srcset: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_003.png :class: sphx-glr-single-img .. rst-class:: sphx-glr-script-out .. code-block:: none
.. GENERATED FROM PYTHON SOURCE LINES 203-216 Tune the decision threshold =========================== Keep the default, prevalence-correct model and change only the decision rule. For this demo we require at least 30 % precision on type 5, then maximize recall. That floor is an explicit product choice: high enough to limit false alarms, low enough that a rare-event model can still catch a useful share of true type-5 stands. Replace 0.3 with a cost or capacity constraint in real work. :class:`~sklearn.model_selection.TunedThresholdClassifierCV` searches the cut-off by cross-validation and does not change ``predict_proba``, so calibration stays intact. .. GENERATED FROM PYTHON SOURCE LINES 216-247 .. code-block:: Python from sklearn.metrics import make_scorer, precision_score, recall_score from sklearn.model_selection import TunedThresholdClassifierCV def recall_with_min_precision(y_true, y_pred, precision_level=0.3): """Maximize recall only among thresholds that keep precision high enough.""" precision = precision_score(y_true, y_pred, zero_division=0) recall = recall_score(y_true, y_pred, zero_division=0) if precision < precision_level: return -np.inf return recall threshold_scoring = make_scorer(recall_with_min_precision, precision_level=0.3) tuned = TunedThresholdClassifierCV( estimator=HistGradientBoostingClassifier(random_state=42), scoring=threshold_scoring, cv=3, n_jobs=4, ) report_tuned = evaluate( tuned, X=X, y=y, pos_label=1, splitter=splitter, ) .. GENERATED FROM PYTHON SOURCE LINES 248-251 SKD004 still fires: label counts did not change. That is expected. We improved how we decide, not the histogram SKD004 reads. Ignore SKD008 to avoid correlated-feature warnings from Covertype's constant soil one-hots. .. GENERATED FROM PYTHON SOURCE LINES 251-254 .. code-block:: Python report_tuned.checks.summarize(fast_mode=True, ignore=["SKD008"]) .. raw:: html


.. GENERATED FROM PYTHON SOURCE LINES 255-262 .. code-block:: Python print("Chosen decision threshold:", float(report_tuned.estimator_.best_threshold_)) report_tuned.metrics.summarize( metric=["accuracy", "precision", "recall", "roc_auc", "log_loss"], data_source="both", ).frame() .. rst-class:: sphx-glr-script-out .. code-block:: none Chosen decision threshold: 0.020068079655242547 .. raw:: html
TunedThresholdClassifierCV (train) TunedThresholdClassifierCV (test)
metric
accuracy 0.966094 0.921875
precision 0.488208 0.241135
recall 1.000000 0.653846
roc_auc 1.000000 0.909586
log_loss 0.005539 0.112095


.. GENERATED FROM PYTHON SOURCE LINES 263-265 .. code-block:: Python report_tuned.metrics.confusion_matrix().plot() .. image-sg:: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_004.png :alt: Confusion Matrix Data source: Test set :srcset: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_004.png :class: sphx-glr-single-img .. rst-class:: sphx-glr-script-out .. code-block:: none
.. GENERATED FROM PYTHON SOURCE LINES 266-275 Compare the default 0.5 cut-off to the tuned threshold. Same underlying probabilities; only the hard predictions change. Precision / recall move; ROC AUC and log-loss stay essentially the same. The scorer asks for precision of at least 0.3, then maximizes recall: catch more type-5 stands without too many false alarms (fraud review, maintenance tickets, medical triage). Preferring high precision instead fits cases where a false alarm is costly: auto-blocking users, expensive tests, or limited outreach budgets. .. GENERATED FROM PYTHON SOURCE LINES 275-287 .. code-block:: Python comparison_thresholds = compare( { "default_threshold_0.5": report, "tuned_threshold": report_tuned, } ) comparison_thresholds.metrics.summarize( metric=["precision", "recall", "roc_auc", "log_loss"], data_source="test", ).frame() .. raw:: html
estimator default_threshold_0.5 tuned_threshold
metric
precision 0.608696 0.241135
recall 0.269231 0.653846
roc_auc 0.909586 0.909586
log_loss 0.112095 0.112095


.. GENERATED FROM PYTHON SOURCE LINES 288-293 Inspect the precision-recall curve ================================== The dashed line is our precision floor (0.3). The tuned threshold should land near the highest-recall point that still respects that floor. .. GENERATED FROM PYTHON SOURCE LINES 293-310 .. code-block:: Python threshold = float(report_tuned.estimator_.best_threshold_) display = report.metrics.precision_recall() fig = display.plot() ax = fig.axes[0] ax.axhline(0.3, linestyle="--", color="gray", label="precision floor 0.3") ax.axvline( report_tuned.metrics.recall(), linestyle=":", color="C1", label=f"recall at threshold={threshold:.3f}", ) ax.legend(loc="best") ax.set_title("Precision-recall curve (test fold)") fig .. image-sg:: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_005.png :alt: Precision-Recall Curve for HistGradientBoostingClassifier Positive label: 1 Data source: Test set, Precision-recall curve (test fold) :srcset: /auto_examples/pitfalls_and_solutions/images/sphx_glr_plot_skd004_high_class_imbalance_005.png :class: sphx-glr-single-img .. rst-class:: sphx-glr-script-out .. code-block:: none
.. GENERATED FROM PYTHON SOURCE LINES 311-319 Collecting more minority data ============================= Gathering more type-5 plots can help the model see the rare class. If acquisition preferentially samples minority rows, train prevalence no longer matches production. Probabilities and thresholds fitted on that mix will be biased unless you correct for the shift. Clearing SKD004 by stuffing minority rows into the table is therefore not automatically a success. .. GENERATED FROM PYTHON SOURCE LINES 321-328 Conclusion ========== SKD004 warns that one class dominates the table. Keep natural prevalence when you need honest probabilities; move the threshold when you need a different precision / recall trade-off. Class weights and resampling are risky shortcuts if calibration matters for the decisions you deploy. .. rst-class:: sphx-glr-timing **Total running time of the script:** (1 minutes 16.986 seconds) .. _sphx_glr_download_auto_examples_pitfalls_and_solutions_plot_skd004_high_class_imbalance.py: .. only:: html .. container:: sphx-glr-footer sphx-glr-footer-example .. container:: sphx-glr-download sphx-glr-download-jupyter :download:`Download Jupyter notebook: plot_skd004_high_class_imbalance.ipynb ` .. container:: sphx-glr-download sphx-glr-download-python :download:`Download Python source code: plot_skd004_high_class_imbalance.py ` .. container:: sphx-glr-download sphx-glr-download-zip :download:`Download zipped: plot_skd004_high_class_imbalance.zip ` .. only:: html .. rst-class:: sphx-glr-signature `Gallery generated by Sphinx-Gallery `_