Use when predicting a column from rows of tabular features with classic models — scikit-learn pipelines, RandomForest, XGBoost/LightGBM, leak-free cross-validation, metrics for imbalanced classes, or a model that aced CV then collapsed in production. NOT PyTorch neural nets (that is `deep-learning`), NOT cleaning the dirty table first (that is `data-cleaning`), NOT forecasting a dated series (that is `forecasting`), NOT text/token modeling (that is `nlp`).
Scanned 9/2/2026
Install to Claude Code
npx -y skills add ericrisco/rsc-harness --skill machine-learning --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Machine Learning?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/ericrisco-machine-learning)More formats (shields.io, HTML) on the badges page.
---
name: machine-learning
description: "Use when predicting a column from rows of tabular features with classic models — scikit-learn pipelines, RandomForest, XGBoost/LightGBM, leak-free cross-validation, metrics for imbalanced classes, or a model that aced CV then collapsed in production. NOT PyTorch neural nets (that is `deep-learning`), NOT cleaning the dirty table first (that is `data-cleaning`), NOT forecasting a dated series (that is `forecasting`), NOT text/token modeling (that is `nlp`)."
tags: [machine-learning, scikit-learn, sklearn, xgboost, lightgbm, tabular, gbdt, cross-validation, data-leakage, classification, regression, pipeline]
recommends: [deep-learning, data-cleaning, python, training-data]
origin: risco
---
# Machine learning — classic/tabular models, done without lying to yourself
Tabular ML is easy to *run* and easy to *fool yourself with*. The deliverable is never "the notebook
printed 0.99" — it is an **honest estimate of how the model behaves on data it has never seen**: a
`Pipeline` that fits every transform on train only, a metric that survives class imbalance, and a
`DummyClassifier` baseline it beats. A number you can't reproduce on a sacred test set you touched exactly
once isn't a result — it's a leak you haven't found yet.
## Is this the right skill? (decide first)
| Your situation | Reach for |
| --- | --- |
| Rows of features, predict a column, with trees / linear models / sklearn | **machine-learning** (this skill) |
| Images, audio, long text, sequences, or you need a neural net / PyTorch | [`deep-learning`](../deep-learning/SKILL.md) |
| The table is still dirty (nulls, dupes, mixed types, bad dates) | [`data-cleaning`](../data-cleaning/SKILL.md) **first** — it hands you a validated table |
| Text/token classification, NER, tokenization, LLM-adjacent NLP metrics | [`nlp`](../nlp/SKILL.md) (a TF-IDF + linear/GBDT baseline still lives happily in this skill's pipeline) |
| KPIs, dashboards, "explain the business" | [`analytics`](../analytics/SKILL.md) / [`business-intelligence`](../business-intelligence/SKILL.md) |
| Forecast a dated series forward (revenue next quarter) | [`forecasting`](../forecasting/SKILL.md) |
| Building a training corpus of JSONL messages / preference pairs for an LLM | [`training-data`](../training-data/SKILL.md) |
This skill **starts** at a clean, validated table (rows × features + a target) and **ends** at a fitted,
honestly-scored model with a test-set number and a baseline it beats. Cleaning is upstream — consume the
validated frame `data-cleaning` produced; don't re-teach it here.
## Version reality (verify at author time — this line moves monthly)
Verified 2026-07: **scikit-learn current major ~1.9** (1.9.0 shipped 2026-06-02, Python 3.11–3.14) — do
NOT pin from memory; check the current stable at scikit-learn.org, the 1.x line ships every few months.
GBDTs: **XGBoost 3.x** and **LightGBM 4.x** (`xgboost` 3.3, `lightgbm` 4.6 current), plus sklearn's own
**`HistGradientBoostingClassifier`/`...Regressor`** — a fast native GBDT that eats NaN and (with
`categorical_features="from_dtype"`) categoricals with **no** preprocessing. Pin what you ship
([`python`](../python/SKILL.md) owns the environment and the pinning); state versions as "~X (verify)",
never as frozen fact.
## The one rule everything else serves: fit on train only
Every preprocessing step — imputation, scaling, encoding, feature selection, target encoding, resampling —
learns parameters from data. Learn them from rows the model is later *scored* on and the score inflates
while production underperforms: that is **leakage**, the #1 way tabular ML lies. The whole apparatus below —
`Pipeline`, `ColumnTransformer`, CV, the untouched test set — exists to make "fit on train only" automatic
instead of something you remember to do by hand (you won't).
## scikit-learn: estimators, Pipeline, ColumnTransformer
Every model is an **estimator** with the same contract: `fit(X, y)`, then `predict(X)` /
`predict_proba(X)` (classifiers) / `score(X, y)`. Transformers add `transform(X)` / `fit_transform(X, y)`.
A **`Pipeline`** chains transformers + a final estimator into one estimator — so `fit` fits every step on
train, and `predict`/CV `transform`s test data with parameters learned on train. That is the leakage guard.
A **`ColumnTransformer`** routes different columns down different transformer branches (scale the numerics,
encode the categoricals) and stitches the result back together — all still *inside* the pipeline.
```python
from sklearn.compose import ColumnTransformer, make_column_selector as mcs
from sklearn.pipeline import Pipeline
from sklearn.impute import SimpleImputer
from sklearn.preprocessing import StandardScaler, OneHotEncoder
from sklearn.linear_model import LogisticRegression
numeric = Pipeline([("impute", SimpleImputer(strategy="median")),
("scale", StandardScaler())]) # scaling matters for LINEAR/SVM/KNN
categoric = Pipeline([("impute", SimpleImputer(strategy="most_frequent")),
("onehot", OneHotEncoder(handle_unknown="ignore"))]) # unseen category -> all-zeros, no crash
pre = ColumnTransformer([
("num", numeric, mcs(dtype_include="number")),
("cat", categoric, mcs(dtype_include=["object", "category"])),
], remainder="drop")
model = Pipeline([("pre", pre), ("clf", LogisticRegression(max_iter=1000, class_weight="balanced"))])
model.fit(X_train, y_train) # imputers/scaler/encoder ALL fit on X_train only
model.predict_proba(X_test) # X_test transformed with train-learned params — no leak
```
Two load-bearing details: `OneHotEncoder(handle_unknown="ignore")` so a category unseen in train doesn't
crash prediction, and `remainder="drop"` so unrouted columns don't silently leak through raw;
`model.set_output(transform="pandas")` keeps named-column DataFrames through the pipeline.
**Trees skip most of this** — scaling is pointless for tree models, and `HistGradientBoostingClassifier`
ingests NaN and categoricals natively, so its pipeline is often just the estimator. Preprocess for the
*model that needs it*, not as a ritual. More patterns (`make_column_transformer`, `FunctionTransformer`)
→ [references/pipelines-and-cv.md](references/pipelines-and-cv.md).
## GBDTs are your default on tabular data
Gradient-boosted decision trees are the correct first (and usually last) model for tabular problems.
This isn't taste: **Grinsztajn, Oyallon & Varoquaux (2022), "Why do tree-based models still outperform
deep learning on tabular data?"** ([arXiv:2207.08815](https://arxiv.org/abs/2207.08815)) benchmarked across
45 datasets and found GBDTs beat tuned neural nets on medium-sized tabular data, tracing it to three
inductive biases NNs lack: **robustness to uninformative features**, **not being rotationally invariant**
(so they exploit the meaning of individual columns), and ease of **learning irregular / non-smooth target
functions**. Start with a GBDT; reach for `deep-learning` on tabular only with a specific reason.
| Library | Import | Reach for it when |
| --- | --- | --- |
| `HistGradientBoostingClassifier` | `sklearn.ensemble` | Default. Fast, zero extra deps, native NaN + categorical. |
| XGBoost 3.x | `xgboost.XGBClassifier` | Battle-tested; `early_stopping_rounds`, rich regularization. |
| LightGBM 4.x | `lightgbm.LGBMClassifier` | Fastest on wide/large data; leaf-wise; strong native categoricals. |
All three expose the sklearn estimator API, so they drop into the pipeline and CV below unchanged. Don't
agonize over XGBoost-vs-LightGBM before you have a baseline and a leak-free CV — split discipline dwarfs the
library choice. Tuning knobs (`learning_rate`, `num_leaves`/`max_depth`, early stopping, `monotonic_cst`)
and importance/SHAP → [references/gbdt-and-tuning.md](references/gbdt-and-tuning.md).
## Leakage-safe feature engineering
Feature engineering is where leakage sneaks back in after the pipeline "protected" you. Rules:
- **Any transform that learns from data goes INSIDE the pipeline**, so CV re-fits it per fold. A scaler fit
on the whole dataset, a `SelectKBest` run before splitting, an imputer using the global mean — each leaks
test statistics into train. The `common_pitfalls` canonical example: `SelectKBest(k=25).fit_transform(X, y)`
*before* `train_test_split` produces a beautiful, meaningless score.
- **Target/mean encoding of high-cardinality categoricals must cross-fit.** sklearn's
`preprocessing.TargetEncoder` does this: its `fit_transform(X, y)` uses an internal cross-fitting scheme
so each row is encoded from *other* folds' targets — `fit(X, y).transform(X)` deliberately differs and
would leak. Use `fit_transform` on train (inside the pipeline); never hand-roll a group-mean encoder.
- **No target-derived features.** A column computed from the label (or a near-proxy: "was_refunded" when
predicting "will_refund") is leakage wearing a feature's clothes. If a feature is impossibly predictive,
suspect it.
- **Respect time.** With any temporal structure, a feature may use only information available *at prediction
time* — no future aggregates, no lifetime values that include post-cutoff rows. Split by time
(`TimeSeriesSplit`), not randomly.
## Split + cross-validation: protect the sacred test set
Hold out a **test set once, at the very start** (`train_test_split(..., stratify=y, random_state=0)`) and
do not look at it until you have a single final model. Every glance — tuning, feature choice, "let me just
check" — bleeds information and re-inflates the estimate. Tune and compare with **cross-validation on the
training portion only**; the test set is the one honest number at the end (see the lifecycle below). Score
with `cross_validate(pipeline, X_tr, y_tr, cv=cv, scoring=[...], return_train_score=True)` — a large
train-minus-test gap is your overfitting alarm.
Pick the splitter to match the data ([cross_validation docs](https://scikit-learn.org/stable/modules/cross_validation.html)):
- **`StratifiedKFold`** — default for classification; preserves class balance per fold (essential when
imbalanced). `KFold` for regression.
- **`TimeSeriesSplit`** — any time ordering. Trains on past, tests on future; never shuffles the future
into train. A random `KFold` on time-series data is leakage.
- **`GroupKFold` / `StratifiedGroupKFold`** — when rows cluster (same user/patient/store across many rows).
Keep a group entirely in train *or* test, or the model memorizes the group and CV lies.
- Pass **integer `random_state`** to splitters for reproducible folds. Put preprocessing in the pipeline so
CV re-fits it every fold — `cross_validate(pipeline, ...)`, never `cross_validate(model, X_scaled, ...)`.
For tuning, wrap CV in `GridSearchCV` / `RandomizedSearchCV` / `HalvingRandomSearchCV`; for an unbiased
estimate *of the tuning process itself*, use nested CV → [references/pipelines-and-cv.md](references/pipelines-and-cv.md).
## Metrics: the accuracy trap and what to use instead
**Accuracy lies on imbalanced data.** At 99% negatives, a model that predicts "negative" always scores 99%
accuracy and is worthless. Choose the metric for the task and the cost of each error type
([model_evaluation docs](https://scikit-learn.org/stable/modules/model_evaluation.html)):
| Task / question | Metric (`sklearn.metrics`) | scoring string |
| --- | --- | --- |
| Ranking quality, threshold-free, balanced-ish | `roc_auc_score` | `"roc_auc"` |
| **Imbalanced** ranking (rare positive: fraud, disease) | `average_precision_score` (PR-AUC) | `"average_precision"` |
| Cost of false positives high (don't cry wolf) | `precision_score` | `"precision"` |
| Cost of misses high (don't miss a case) | `recall_score` | `"recall"` |
| Balance both, per-class fairness | `f1_score` (use `f1_macro` multiclass) | `"f1"` / `"f1_macro"` |
| Multiclass, care about every class equally | `balanced_accuracy_score` | `"balanced_accuracy"` |
| See the actual error breakdown | `confusion_matrix`, `classification_report` | — |
| Regression | `r2_score`, `mean_absolute_error`, `root_mean_squared_error` | `"r2"`, `"neg_mean_absolute_error"`, `"neg_root_mean_squared_error"` |
**Prefer PR-AUC (`average_precision`) to ROC-AUC when positives are rare** — ROC-AUC can look great while
precision is dismal, since it ignores the negative flood. Feed AUC metrics `predict_proba`, not hard labels.
The default 0.5 threshold is a *choice*: tune it on validation to hit your precision/recall target.
Regression uses `root_mean_squared_error` now (`mean_squared_error(squared=False)` is gone). Threshold
tuning, calibration, `class_weight`/resampling → [references/metrics-and-imbalance.md](references/metrics-and-imbalance.md).
## Anti-patterns — the cardinal sins
| Anti-pattern | Do instead |
| --- | --- |
| Modeling straight off the raw, dirty table | This skill starts at a **clean, validated frame**. Run [`data-cleaning`](../data-cleaning/SKILL.md) first — nulls, dupes and mixed dtypes are its job, not a modeling problem. |
| Scaling / encoding / selecting features, *then* splitting | **Leakage (#1 sin).** Test statistics are now in train. Split first; put every learned transform *inside* the `Pipeline` so CV re-fits per fold. |
| Celebrating an amazingly predictive feature | Suspect **target leakage** — a column derived from the label or unavailable at prediction time. Audit provenance before you celebrate. |
| Reporting 99% accuracy on 1% positives | The **accuracy trap**. A constant predictor matches it. Report PR-AUC / precision / recall / F1 and a `confusion_matrix`. |
| `SMOTE`-ing the whole dataset because the classes are imbalanced | Resampling *before* the split, or on the test fold, leaks and evaluates on synthetic rows. Resample **inside CV, on the train fold only** (imblearn `Pipeline`), or just use `class_weight="balanced"`. |
| Shipping on the CV score alone | You never touched a **held-out test set**, or you peeked at it while tuning. One final, untouched test number — or the estimate is optimistic. |
| Random `KFold` on time-series / multi-user data | Future or same-group rows leak into train. Use `TimeSeriesSplit` / `GroupKFold`. |
| Waving off a big train/test gap | **Overfitting.** Regularize, reduce capacity (`max_depth`, `min_samples_leaf`), get more data, or use early stopping. Watch `return_train_score`. |
| Going straight to XGBoost with no baseline | Without a **`DummyClassifier(strategy="most_frequent")`** / `DummyRegressor` floor (and a simple linear model), you can't tell if the fancy model adds anything. |
| Grid-searching 10k combos over all the data | Tuning against the test set *is* fitting to it. Tune with CV on train, confirm once on test; consider nested CV. |
| Unset `random_state`, unpinned versions | Folds and fits stop being reproducible and re-trains stop being comparable. Set an integer `random_state` on splitters *and* estimators; pin the versions you ship. |
## Worked lifecycle (end to end)
```python
from sklearn.dummy import DummyClassifier
from sklearn.ensemble import HistGradientBoostingClassifier
from sklearn.model_selection import train_test_split, StratifiedKFold, cross_val_score
from sklearn.metrics import average_precision_score, classification_report
X_tr, X_test, y_tr, y_test = train_test_split(X, y, test_size=0.2, stratify=y, random_state=0)
cv = StratifiedKFold(n_splits=5, shuffle=True, random_state=0)
# 0. BASELINE first — the floor every real model must clear
base = DummyClassifier(strategy="most_frequent")
print("baseline PR-AUC:", cross_val_score(base, X_tr, y_tr, cv=cv, scoring="average_precision").mean())
# 1. default GBDT (native NaN + categoricals -> minimal pipeline). class_weight for imbalance.
clf = HistGradientBoostingClassifier(categorical_features="from_dtype",
class_weight="balanced", random_state=0)
cv_pr = cross_val_score(clf, X_tr, y_tr, cv=cv, scoring="average_precision")
print("model CV PR-AUC:", cv_pr.mean().round(3), "+/-", cv_pr.std().round(3))
# 2. it clears the baseline -> commit, fit on all train, judge ONCE on the sacred test set
clf.fit(X_tr, y_tr)
proba = clf.predict_proba(X_test)[:, 1]
print("TEST PR-AUC:", round(average_precision_score(y_test, proba), 3))
print(classification_report(y_test, (proba >= 0.5).astype(int))) # threshold is a choice — tune it
```
## Project grounding (02-DOCS + CLAUDE.md)
In a project with a `02-DOCS/` layer (the [`harness`](../harness/SKILL.md) wiki), record the modeling
contract in `02-DOCS/wiki/ml/<target>.md`, linked from the root `CLAUDE.md` `## Knowledge map`: target
definition, split strategy + `random_state`, CV scheme, chosen metric and *why*, baseline, pinned versions,
and the dated final test-set score. Read it first on every re-train so results stay comparable. No
`02-DOCS/`? Skip silently — conventions are recorded, never gated.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!