Aligning an External Cohort
Every example in this section continues with the well-known Adult Income
dataset [1] used throughout the rest of this documentation, and with the
Logistic Regression, Decision Tree, and Random Forest classifiers trained on it
using the model_tuner library [2].
Click here to view the corresponding codebase for this workflow.
The external cohort is the American Community Survey (ACS) extract distributed
by folktables [3], whose ACSIncome task was constructed specifically as
a modern successor to Adult Income. It predicts the same outcome, above or below
a $50,000 income threshold, from broadly the same kinds of features, which makes
it a natural external validation set for a model trained on the 1994 census
extract. It is also a realistic one: nothing about its column names, codings, or
units matches what the Adult Income model expects.
That mismatch is the subject of this page.
Why Not a Mapping Loop
Applying a fitted model to a cohort it was not trained on is rarely a matter of
calling predict. The external frame arrives in its own naming convention,
collects some variables the model never saw, and omits others the model
requires. The usual response is a hand-written mapping loop, which works until
it does not, and it fails silently in three distinct ways:
A source column is absent. The loop reads a column that is not in the incoming frame and raises far from the line that caused it, or worse, was wrapped in a
tryand skipped.A destination is misspelled.
ext["capital_gain"] = ...when the model expectscapital-gain. Pandas creates the new column without complaint, the model never receives the value, and nothing raises.An expected feature is never touched. The column exists because the frame was pre-allocated, stays entirely NaN, and the model consumes it as missing for every row.
Each of these produces predictions. None of them produces an error. The resulting AUC looks plausible, which is precisely the problem.
align_features builds the aligned frame declaratively and checks all three
conditions before returning anything. Once the frame is aligned, the
Per-Model Ground Truth support in the evaluation functions lets the ACS
cohort be scored alongside the Adult Income test set in one call.
Discovering the Feature Contract
Before anything can be aligned, the model has to be asked what it wants.
get_expected_features resolves the ordered feature names a fitted estimator
expects at predict time, across the shapes a model actually arrives in.
- get_expected_features(model)
- Parameters:
model (estimator, Pipeline, or wrapper) – Any fitted estimator, pipeline, or wrapper.
- Returns:
Ordered list of feature names the model expects at predict time.
- Return type:
- Raises:
If no name information is reachable from the object or any of its wrapper attributes.
If the wrapper chain is cyclic.
Important
For a fitted
ColumnTransformer, the raw pre-transform input columns are reconstructed fromtransformers_. That is what you build an external frame against; the estimator’s own names are post-transform and carrynum__/cat__prefixes.For a
Pipeline,feature_names_in_is preferred (the raw input to the pipeline). The first step is consulted only when the pipeline never recorded it.Wrapper objects are descended automatically. The chain covers
best_estimator_,estimator_,estimator,regressor_,classifier_,base_estimator,calibrated_classifiers_,model,_model,pipeline, andtest_model, which between them covermodel_tuner.Model[2],GridSearchCV, calibration wrappers,TransformedTargetRegressor, and most hand-rolled wrappers.
Notes
- Resolution Order:
ColumnTransformerraw input columns, whentransformers_is present.Pipeline:feature_names_in_, then the first step.Bare estimator:
feature_names_in_,feature_name_,feature_names_.XGBoost sklearn wrapper:
get_booster().feature_names.Native
xgboost.Booster:feature_names.LightGBM
Booster:feature_name().statsmodels results:
exog_names.Wrapper attributes, recursively.
- When It Raises:
A model that was fitted on a bare NumPy array records no names, so nothing is reachable. Pass
expected=[...]toalign_featuresexplicitly in that case.
Asking the Adult Income Random Forest what it expects returns the feature contract the external frame has to satisfy:
from model_metrics import get_expected_features
get_expected_features(model_rf)
Output
['age',
'fnlwgt',
'education-num',
'capital-gain',
'capital-loss',
'hours-per-week']
Building the Aligned Frame
- align_features(df, model=None, col_map=None, derived=None, expected=None, *, on_unmapped='warn', on_all_nan='warn', dtype='float', copy_passthrough=False, fill=None, report_fill=True)
- Parameters:
df (pandas.DataFrame) – The incoming external frame, in its own naming convention.
model (estimator, optional) – Any fitted estimator. Used to resolve
expectedwhen that is not given directly.col_map (dict, optional) – Mapping of
{source_column: destination}, or{source_column: [dest_a, dest_b]}when one source feeds several model features.derived (dict, optional) – Mapping of
{destination: callable(df) -> array}or{destination: array-like}, for features that are computed rather than renamed.expected (iterable of str, optional) – Explicit ordered feature list. Overrides whatever the model reports, and is the escape hatch when the model carries no names.
on_unmapped (str, optional) – What to do about expected features that no mapping ever touched and that were not filled. One of
"raise","warn", or"ignore". Defaults to"warn".on_all_nan (str, optional) – What to do about features that were mapped but came out entirely NaN, which usually means the source column exists in the external frame but was never populated. One of
"raise","warn", or"ignore". Defaults to"warn".dtype (str, optional) – Cast applied to the result, or
Noneto leave dtypes alone. Defaults to"float".copy_passthrough (bool, optional) – Also carry over any column of
dfwhose name already matches an expected feature, without needing an entry incol_map. Defaults toFalse.fill (scalar, dict, or callable, optional) – Value or values for expected features that no mapping touched, meaning variables the external cohort does not collect at all. Accepts a scalar (same value for every unmapped column), a dict
{name: value}(per column; columns absent from the dict stay NaN), or a callablename -> value(returningNoneleaves that column NaN). DefaultNoneleaves them NaN, which delegates to the model’s own missing-value handling and asserts nothing. See External Validation Example 3: Choosing a Fill.report_fill (bool, optional) – Emit a warning listing exactly which columns were filled with what, so the assumption is visible in logs and reproducible in a write-up. Defaults to
True.
- Returns:
A DataFrame containing exactly
expected, in order, indexed likedf.- Return type:
pandas.DataFrame
- Raises:
If a source column named in
col_mapis absent fromdf.If a destination is named that the model never asked for.
If a destination would be written more than once.
If
expectedcontains duplicate names.If
fillis a dict naming a column that was mapped or is not a model feature.If
on_unmapped="raise"and any expected feature was never mapped.If
on_all_nan="raise"and any mapped feature is entirely NaN.
If neither
modelnorexpectedis provided.If a
derivedcolumn has a length other thanlen(df).If
on_unmappedoron_all_nanis not one of the three recognized modes.
Important
Supply either
model(from which the contract is read) orexpected(the contract stated directly).expectedwins when both are given.The three failure modes are checked before any data is written, so a misspelled destination raises rather than producing a frame that scores.
derivedis for columns the external cohort must compute rather than rename, such as a unit conversion or a code crosswalk. Anything acol_mapentry can express should stay incol_map, where the collision check applies.The returned frame is ordered to match
expected. This matters for plain scikit-learn estimators, which match features by position rather than by name and will otherwise silently misread a correctly named frame.
Notes
- The Three Checks:
Source absent: every key of
col_mapmust exist indf. Raises with the sorted list of offenders.Unknown destination: every destination, from
col_mapandderivedalike, must appear inexpected. The error prints the full expected list, which is usually enough to spot the typo.Never mapped: any expected feature that no mapping touched is reported through
on_unmapped. This is the one that is a warning by default, because it is often legitimate.
- Collisions:
Two sources writing the same destination raises rather than letting the second overwrite the first. This catches a copy-paste error in a long mapping dict, where the same destination is typed twice with different sources.
- Genuine Missingness vs Structural Absence:
A column that was mapped but is NaN for some rows is real per-record missingness, and is left alone.
A column that was never mapped is structurally absent: the external cohort does not collect that variable for anybody. Only these are eligible for
fill, which is why afilldict naming a mapped column raises.
External Validation Example 1: A Straight Rename
Several ACS columns are the same quantity the Adult Income model already knows,
recorded under the census PUMS variable name. AGEP is age, WKHP is
hours-per-week. Those need nothing more than a rename.
copy_passthrough=True additionally picks up any column that already matches
an expected feature by name, so only the genuinely different names need an
entry.
from model_metrics import align_features
ext = align_features(
X_acs,
model=model_rf,
col_map={
"AGEP": "age",
"WKHP": "hours-per-week",
},
copy_passthrough=True,
)
y_prob_ext = model_rf.predict_proba(ext)[:, 1]
Output
/tmp/ipykernel_212836/2168851049.py:3: UserWarning:
4 expected feature(s) never mapped, left all-NaN: ['fnlwgt', 'education-num', 'capital-gain', 'capital-loss']
Any expected feature that neither the mapping nor the passthrough covered is reported by name in a warning, so the gap is visible before the predictions are used rather than after.
External Validation Example 2: Derived Columns
Most of the ACS columns are not straight renames. SCHL records educational
attainment on a 1 to 24 scale, while the Adult Income model was fitted on
education-num, which runs 1 to 16. Passing SCHL through unchanged would
hand the model values six points past anything it saw in training, and every
master’s-and-above record would collapse into the same terminal leaves as a
doctorate.
derived takes a callable applied to the whole incoming frame, so the
crosswalk lives next to the mapping it belongs to rather than in a preprocessing
cell three notebooks away.
# ACS SCHL (1-24) crosswalked to Adult Income education-num (1-16)
SCHL_TO_EDNUM = {
1: 1, 2: 1, 3: 1, # none / preschool / kindergarten
4: 2, 5: 2, 6: 2, 7: 2, # grades 1-4
8: 3, 9: 3, # grades 5-6
10: 4, 11: 4, # grades 7-8
12: 5, 13: 6, 14: 7, # grades 9, 10, 11
15: 8, # 12th, no diploma
16: 9, 17: 9, # HS diploma, GED
18: 10, 19: 10, # some college
20: 12, # Associate's
21: 13, 22: 14, 23: 15, 24: 16,
}
col_map={
"AGEP": "age",
"WKHP": "hours-per-week",
}
derived={
"education-num": lambda d: d["SCHL"].map(SCHL_TO_EDNUM),
}
ext = align_features(
X_acs,
col_map=col_map,
derived=derived,
model=model_rf,
)
Output
/tmp/ipykernel_212836/3837904464.py:15: UserWarning:
3 expected feature(s) never mapped, left all-NaN: ['fnlwgt', 'capital-gain', 'capital-loss']
Note
A derived callable receives the incoming frame, not the frame under
construction, so it can reference any external column regardless of whether
that column is mapped. Its return value is length-checked against df.
Important
A crosswalk is a modelling decision in its own right, and the places where
it is not one-to-one deserve to be stated. ACS records a single Associate’s
degree code, while Adult Income splits Assoc-voc (11) from
Assoc-acdm (12); the mapping above sends all of them to 12. Choosing 11
instead is equally defensible and will move the predictions slightly. Write
the choice down rather than leaving it implicit in a dict.
External Validation Example 3: Choosing a Fill
Some model features have no counterpart in the external cohort at all. The
Adult Income model was fitted on capital-gain and capital-loss; ACS PUMS
records no realized-capital-gains variable, so nothing in the incoming frame can
supply them. What to do about that is the most consequential decision in the
whole alignment, and it is a modelling decision rather than a formatting one.
The default, fill=None, leaves the column NaN. For a model that handles
missing values natively (CatBoost, XGBoost, HistGradientBoosting) this delegates
to the model’s own learned handling and asserts nothing about the external
records. It is the honest default and usually the right one.
Filling asserts something:
ext = align_features(
X_acs,
model=model_rf,
col_map=col_map,
derived=derived,
fill={"capital-gain": 0, "capital-loss": 0},
)
Output
/home/lshpaner/Python_Projects/model_metrics/metrics_venv/lib/python3.12/site-packages/IPython/core/interactiveshell.py:3747: UserWarning:
2 unmapped column(s) filled rather than left missing: capital-gain=0, capital-loss=0. This asserts a value the external cohort never measured; record it wherever the results are reported.
/tmp/ipykernel_212836/629044355.py:1: UserWarning:
1 expected feature(s) never mapped, left all-NaN: ['fnlwgt']
Filling those with 0 asserts “nobody in this cohort had any capital gain or
loss”. Zero happens to be the modal value in Adult Income for both columns, so
the fill looks harmless. It is not: the nonzero tail of capital-gain is
almost purely >50K, which is why it typically ranks among the model’s
strongest features. Zeroing it removes the model’s best positive signal, the
external predictions come back systematically low, and they come back low in a
way that correlates with race and sex, because capital income does. A fairness
audit will pick that up and attribute it to the model.
With report_fill=True (the default), a warning names every column and its
value, so the assumption appears in the notebook log:
UserWarning: 2 unmapped column(s) filled rather than left missing:
capital-gain=0, capital-loss=0. This asserts a value the external cohort
never measured; record it wherever the results are reported.
Important
Fix the fill in advance, from what is known about the cohort. Selecting it by comparing downstream AUC is fitting a preprocessing choice to the evaluation set, and the resulting performance estimate is optimistically biased in exactly the way external validation is supposed to guard against.
Notes
- Scalar, dict, or callable:
A scalar fills every unmapped column with the same value. Rarely what you want unless the features are homogeneous, for example a block of one-hot indicators where
0means “level not set”.A dict fills per column and leaves anything absent from the dict as NaN, which is the right shape when some absent features have a defensible default and others do not.
A callable
name -> valuesuits a regular naming convention; returningNonefor a name leaves that column NaN.
- Interaction with the model:
A model that cannot consume NaN, such as a plain
RandomForestClassifier, forces the question, since leaving the column missing is not an option. That constraint is a property of the estimator, not evidence that filling is correct.
- What not to fill:
Features whose absence is the whole finding. When the external cohort does not collect what the model leans on most, a fill produces a number where the honest answer is that this model cannot be validated on this cohort as specified. Refitting on the shared feature set gives a worse model whose degradation actually means something.
- A note on ``fnlwgt``:
Adult Income models are frequently fitted on
fnlwgt, a CPS survey weight that is a function of the sampling design and carries no information about the individual. It has no external counterpart worth constructing, and its presence in the feature set is usually an artifact of feeding every numeric column to the estimator. It is worth removing from the model rather than filling.
External Validation Example 4: Failing Loudly
For a pipeline run rather than exploratory work, promote both reports to exceptions so a silently degraded frame cannot reach the evaluation step.
ext = align_features(
X_acs,
model=model_rf,
col_map=mapping,
derived=derived,
on_unmapped="raise",
on_all_nan="raise",
)
Output
---------------------------------------------------------------------------
FeatureContractError Traceback (most recent call last)
Cell In[25], line 1
----> 1 ext = align_features(
2 X_acs,
3 model=model_rf,
4 col_map=col_map,
File ~/Python_Projects/model_metrics/src/model_metrics/align_features.py:291, in align_features(df, model, col_map, derived, expected, on_unmapped, on_all_nan, dtype, copy_passthrough, fill, report_fill)
288 still_nan = [c for c in unmapped if c not in filled]
290 # --- report gaps ------------------------------------------------------- #
--> 291 _report(
292 on_unmapped,
293 f"{len(still_nan)} expected feature(s) never mapped, left all-NaN: {still_nan}",
294 still_nan,
295 )
297 all_nan = [
298 c for c in expected if c not in unmapped and c not in filled and ext[c].isna().all()
299 ]
300 _report(
301 on_all_nan,
302 f"{len(all_nan)} mapped feature(s) are entirely NaN after alignment: {all_nan}",
303 all_nan,
304 )
File ~/Python_Projects/model_metrics/src/model_metrics/align_features.py:358, in _report(mode, msg, payload)
356 return
357 if mode == "raise":
--> 358 raise FeatureContractError(msg)
359 if mode == "warn":
360 warnings.warn(msg, stacklevel=3)
FeatureContractError: "3 expected feature(s) never mapped, left all-NaN: ['fnlwgt', 'capital-gain', 'capital-loss']"
on_unmapped="raise" requires every model feature to be accounted for, by a
mapping, a derivation, or an explicit fill. on_all_nan="raise" catches
the subtler case where a mapping is syntactically valid but the source column
turns out to be empty in this cohort.
External Validation Example 5: Scoring Both Cohorts Together
Once the ACS frame is aligned, the Adult Income test set and the external cohort
can be passed to any evaluation function as a pair, using the per-model list
form of y described in Per-Model Ground Truth. The two cohorts have
different row counts and share no observations, which is exactly what that form
exists to express.
import numpy as np
from model_metrics import (
combine_plots,
show_roc_curve,
show_pr_curve,
show_calibration_curve,
)
p_int = model_rf.predict_proba(X_test)[:, 1]
p_ext = model_rf.predict_proba(ext)[:, 1]
INT_TITLE = "Adult Income 1994 (internal)"
EXT_TITLE = "ACS (external)"
SHARED = {
"y_prob": [p_int, p_ext],
"y": [np.asarray(y_test).ravel(), y_acs],
"model_title": [INT_TITLE, EXT_TITLE],
"overlay": True,
"curve_kwgs": {
INT_TITLE: {"color": "black", "linewidth": 1.5},
EXT_TITLE: {"color": "#C1440E", "linewidth": 1.5},
},
}
combine_plots(
plot_calls=[
(show_roc_curve, {**SHARED, "title": "Discrimination"}),
(show_pr_curve, {**SHARED, "legend_metric": "ap",
"title": "Precision-Recall"}),
(show_calibration_curve, {**SHARED, "bins": 10,
"title": "Calibration"}),
],
n_cols=3,
n_rows=1,
figsize=(19, 5.5),
suptitle="Adult Income classifier: internal vs external validation",
)
Output
Important
Read the three panels differently. AUC ROC is invariant to the positive rate and transfers cleanly. Average Precision and the Brier score both move with prevalence, so a worse external value does not by itself indicate a worse model. This matters more than usual here: the positive rate in Adult Income is roughly 24%, while ACSIncome sits closer to 40% depending on the state and survey year, because the same nominal $50,000 threshold is a very different threshold in 2018 dollars than in 1994 dollars. The calibration panel is where that shift shows most clearly.
Exceptions
- exception FeatureContractError
Raised when a mapping does not satisfy a model’s expected feature set. Subclasses
KeyError, so existingexcept KeyErrorhandlers continue to catch it, while code that wants to distinguish a contract violation from an ordinary missing key can catch the specific type.
from model_metrics import align_features, FeatureContractError
try:
ext = align_features(
X_acs,
model=model_rf,
col_map=mapping,
on_unmapped="raise",
)
except FeatureContractError as exc:
print(f"External frame does not satisfy the model contract:\n{exc}")