Skip to content

Predictive Modeling API Reference

Feature Analytics

Use feature-analytics helpers (early-performance rates, WoE/IV, feature engineering, lift/gain, score calibration and monitoring) to prepare and validate signal before fitting a final estimator. No single run() here — these are independent analytics tools rather than one pipeline.

cranalytics.feature_analytics

Feature analytics compatibility re-export surface.

This package groups early-performance rate/separation analytics (_early_performance), feature engineering and WoE binning (_model_development), and score calibration/monitoring (_score_monitoring) — independent analytics tools rather than one end-to-end pipeline, so there is no single run() entry point here. Reach for the specific helper you need.

This module carries no logic of its own — it only re-exports the focused submodules so that existing from cranalytics.feature_analytics import ... call sites keep working. Add new behavior to the appropriate submodule, not here.

calculate_early_performance_rates(df: pd.DataFrame, flag_columns: list[str], weight_col: str | None = None, confidence: float = 0.95, reliability_thresholds: tuple[int, int] = (10, 30)) -> pd.DataFrame

Compute portfolio-level event rates and Wilson confidence intervals.

Returns one row per flag with n_mature, n_events, n_event_loans, rate, ci_lower, ci_upper and reliability ("low", "medium" or "high" from n_event_loans; see compute_segment_rates for the thresholds).

compute_conditional_loss_table(df: pd.DataFrame, segment_cols: list[str], flag_col: str, outcome_col: str, n_bins_score: int = 5, score_col: str | None = None, weight_col: str | None = None, confidence: float = 0.95) -> pd.DataFrame

Compute conditional lifetime-loss summaries by segment, score bucket, and flag.

compute_marginal_impact(df: pd.DataFrame, feature_col: str, flag_col: str, control_col: str, n_bins_control: int = 5, n_bins_feature: int | None = None, weight_col: str | None = None) -> pd.DataFrame

Estimate feature effect within control strata via within-bucket rate deltas.

compute_segment_rates(df: pd.DataFrame, flag_col: str, group_by: str | list[str], weight_col: str | None = None, n_bins: int | None = None, confidence: float = 0.95, reliability_thresholds: tuple[int, int] = (10, 30), adjusted: bool = False) -> pd.DataFrame

Compute mature-event rates by segment(s) with volume and contribution shares.

Beyond the raw rate and its Wilson interval, two plain-language columns say how far to trust each segment:

  • vs_portfolio: "higher" or "lower" when the segment's interval sits entirely above or below the portfolio rate, else "unclear".
  • reliability: "low", "medium" or "high" from the number of loans with an event (n_event_loans), not the number of loans.

Parameters:

Name Type Description Default
df DataFrame

Loan-level data with the outcome flag and segment columns.

required
flag_col str

Binary event column. Null outcomes (immature loans) are excluded.

required
group_by str | list[str]

Segment column or columns.

required
weight_col str | None

Optional exposure weight, e.g. original balance. Intervals then use the effective sample size of the weights.

None
n_bins int | None

Quantile-bin numeric group_by columns into this many bins.

None
confidence float

Interval confidence level.

0.95
reliability_thresholds tuple[int, int]

(low_below, high_at) event counts. Segments with fewer than low_below events are "low", at least high_at are "high", otherwise "medium".

(10, 30)
adjusted bool

Add rate_adjusted, the rate pulled toward the portfolio average when a segment's data is thin. Useful for ranking many segments of very different sizes. rate is left unchanged.

False

Returns:

Type Description
DataFrame

One row per segment with n_mature, n_events, n_event_loans,

DataFrame

rate, ci_lower, ci_upper, vs_portfolio, reliability,

DataFrame

volume_pct, contribution_pct and, with adjusted=True,

DataFrame

rate_adjusted.

Examples:

>>> import pandas as pd
>>> from cranalytics.feature_analytics import compute_segment_rates
>>> frame = pd.DataFrame(
...     {"channel": ["a"] * 400 + ["b"] * 400,
...      "fpf": [1] * 8 + [0] * 392 + [1] * 60 + [0] * 340}
... )
>>> out = compute_segment_rates(frame, "fpf", "channel")
>>> out[["channel", "vs_portfolio", "reliability"]].values.tolist()
[['a', 'lower', 'low'], ['b', 'higher', 'high']]

compute_woe_iv(df: pd.DataFrame, feature_col: str, flag_col: str, n_bins: int = 10, weight_col: str | None = None) -> tuple[pd.DataFrame, float]

Compute a Weight of Evidence table and total Information Value.

Parameters:

Name Type Description Default
df DataFrame

Loan-level feature and outcome data.

required
feature_col str

Candidate feature to bin and evaluate.

required
flag_col str

Binary event column. Null outcomes are excluded.

required
n_bins int

Maximum number of bins for numeric features.

10
weight_col str | None

Optional exposure or observation-weight column.

None

Returns:

Type Description
tuple[DataFrame, float]

Tuple of WoE detail table and total IV.

Examples:

>>> import pandas as pd
>>> from cranalytics.feature_analytics import compute_woe_iv
>>> frame = pd.DataFrame({"fico": [600, 650, 750, 800], "bad": [1, 1, 0, 0]})
>>> table, total_iv = compute_woe_iv(frame, "fico", "bad", n_bins=2)
>>> {"bin", "woe", "iv_contribution"} <= set(table.columns)
True
>>> total_iv >= 0
True

estimate_vintage_lifetime_profit(df: pd.DataFrame, expected_loss_col: str, avg_life_col: str | None = None, avg_life: float | None = None, coupon_rate: float = 0.0, servicing_cost_rate: float = 0.0, funding_cost_rate: float = 0.0) -> pd.DataFrame

Estimate vintage-level net margin from pricing assumptions and expected loss.

Formula::

net_margin = coupon_rate * avg_life
             - expected_loss_rate
             - servicing_cost_rate
             - funding_cost_rate

All rate inputs are decimals (e.g. 0.18 for 18%). avg_life is in years. coupon_rate is an annualised yield multiplied by avg_life to give the lifetime interest income. servicing_cost_rate and funding_cost_rate are lifetime rates (not annualised) and are subtracted directly without scaling by avg_life. expected_loss_rate is likewise a lifetime rate (e.g. from compute_conditional_loss_table).

Parameters

df : DataFrame with at least expected_loss_col (and optionally avg_life_col). expected_loss_col : Column of lifetime loss rates (decimals, e.g. 0.05 = 5%). avg_life_col : Per-row average life column (years). Takes precedence over avg_life. avg_life : Scalar average life (years) applied to all rows. Required if avg_life_col is None. coupon_rate : Annualised all-in yield (decimal); multiplied by avg_life in the formula. servicing_cost_rate : Lifetime servicing cost rate (decimal); not scaled by avg_life. funding_cost_rate : Lifetime cost-of-funds rate (decimal); not scaled by avg_life.

Returns

Copy of df with an added net_margin column.

rank_features_by_separation(df: pd.DataFrame, feature_cols: list[str], flag_col: str, n_bins: int = 10, weight_col: str | None = None) -> pd.DataFrame

Rank candidate features by IV, Gini, and KS against a binary target.

validate_performance_flags(df: pd.DataFrame, flag_columns: list[str]) -> tuple[pd.DataFrame, pd.DataFrame]

Validate binary flag columns and return maturity coverage statistics.

engineer_loan_features(df: pd.DataFrame, *, as_of_date: pd.Timestamp | None = None, reference_date: pd.Timestamp | None = None) -> pd.DataFrame

Add derived credit-risk features to a loan DataFrame.

Source columns that are absent are silently skipped. Original columns are always preserved.

Parameters

df : pd.DataFrame Loan-level DataFrame. as_of_date : pd.Timestamp Snapshot or model development date used to compute loan_age_months. Must be provided explicitly — never default to max(start_date). reference_date : pd.Timestamp, optional Deprecated alias for as_of_date. Will be removed in v2.0.

Returns

pd.DataFrame Copy of df with derived columns appended.

fit_woe_binning(df: pd.DataFrame, feature_cols: list[str], target_col: str, *, special_codes: list | None = None, **binning_kwargs)

Fit an optimal WoE binning process on the provided features.

Wraps optbinning.BinningProcess. Requires optbinning.

Parameters

df : pd.DataFrame feature_cols : list[str] target_col : str Binary target column (0/1). special_codes : list, optional Sentinel values to group into a special bin. **binning_kwargs Passed to BinningProcess.

Returns

optbinning.BinningProcess Fitted process. Call .transform(X, metric="woe") to encode.

lift_gain_table(y_true: pd.Series | np.ndarray, y_prob: pd.Series | np.ndarray, *, n_bins: int = 10) -> pd.DataFrame

Compute lift and gain table from binary labels and predicted probabilities.

Sorts observations by descending score, splits into n_bins equal-sized buckets, and returns per-bin statistics.

Parameters

y_true : array-like Binary labels (0/1). y_prob : array-like Predicted probabilities in [0, 1]. n_bins : int Number of equal-size buckets (default 10).

Returns

pd.DataFrame Columns: bin, n, n_events, event_rate, cumulative_n, cumulative_events, cumulative_gain, lift, score_min, score_max, baseline_rate.

calibrate_score_to_event_rate(df: pd.DataFrame, score_col: str, flag_col: str, n_bins: int = 10, weight_col: str | None = None, method: str = 'binned') -> tuple[pd.DataFrame, dict[str, float | str]]

Calibrate a score to observed target event rates.

compute_actual_vs_expected(df: pd.DataFrame, score_col: str, flag_col: str, group_by: str | list[str], calibration_table: pd.DataFrame | None = None, weight_col: str | None = None, confidence: float = 0.95, n_bins_group: int | None = None, reliability_thresholds: tuple[int, int] = (10, 30)) -> pd.DataFrame

Compare observed event rates to expected rates by segment.

Each segment gets a z-test of actual against expected. The significant flag uses a Bonferroni correction for the number of segments, so slicing many ways does not by itself produce flagged segments. Two plain-language columns summarise the result:

  • vs_expected: "worse" (more events than expected), "better" or "in line", following significant and the sign of z_score.
  • reliability: "low", "medium" or "high" from the number of loans with an event (n_event_loans); see reliability_thresholds.

Parameters:

Name Type Description Default
df DataFrame

Loan-level data with score, outcome flag and segment columns.

required
score_col str

Model score or predicted probability.

required
flag_col str

Binary event column. Null outcomes (immature loans) are excluded.

required
group_by str | list[str]

Segment column or columns.

required
calibration_table DataFrame | None

Output of calibrate_score_to_event_rate mapping scores to expected rates. Without it the raw score is used.

None
weight_col str | None

Optional exposure weight. Intervals then use the effective sample size of the weights.

None
confidence float

Confidence level for the intervals and the z-test.

0.95
n_bins_group int | None

Quantile-bin numeric group_by columns into this many bins.

None
reliability_thresholds tuple[int, int]

(low_below, high_at) event counts. Segments with fewer than low_below events are "low", at least high_at are "high", otherwise "medium".

(10, 30)

Returns:

Type Description
DataFrame

One row per segment, sorted by z_score descending, with

DataFrame

n_mature, n_event_loans, expected_rate, actual_rate,

DataFrame

actual_ci_lower, actual_ci_upper, ratio_a_e,

DataFrame

ae_ci_lower, ae_ci_upper, z_score, significant,

DataFrame

vs_expected and reliability. The intervals are at the nominal

DataFrame

confidence level; the A/E interval divides the actual-rate interval

DataFrame

by the expected rate.

compute_psi(expected: pd.Series, actual: pd.Series, n_bins: int = 10, bin_edges: list[float] | None = None, expected_weights: pd.Series | None = None, actual_weights: pd.Series | None = None) -> tuple[pd.DataFrame, float]

Compute population stability index between expected and actual distributions.

score_performance_monitoring_report(df: pd.DataFrame, score_col: str, flag_col: str, group_by: str | list[str] | None = None, baseline_df: pd.DataFrame | None = None, n_bins: int = 10, weight_col: str | None = None) -> dict

Bundled score monitoring report: discrimination, calibration, A/E, PSI.

Parameters

df : Scored portfolio DataFrame with mature flags. score_col : Numeric score column (higher = higher risk). flag_col : Binary 0/1 event flag (NaN = immature). group_by : Column(s) for actual-vs-expected breakdown. Omitted if None. baseline_df : Reference period DataFrame for PSI. Omitted if None. n_bins : Score bins for calibration table. weight_col : Optional loan-balance weight.

Returns

dict with keys:

  • "discrimination": dict of auc, gini, ks, spearman_rho
  • "calibration_table": DataFrame from calibrate_score_to_event_rate
  • "calibration_stats": summary stats dict
  • "actual_vs_expected": DataFrame or None (requires group_by)
  • "psi": {"table": DataFrame, "total_psi": float} or None (requires baseline_df)

simulate_policy_cutoff(df: pd.DataFrame, flag_col: str, cutoff_col: str, cutoffs: list[float], direction: str = 'min', weight_col: str | None = None) -> pd.DataFrame

Simulate the volume/loss trade-off across candidate policy cutoffs.

For each cutoff value, computes approval rate, projected event rate, volume lost, and loss reduction relative to no cutoff (all mature loans).

Parameters

df : DataFrame with mature-flag and cutoff columns. flag_col : Binary 0/1 event flag (NaN = immature, excluded). cutoff_col : Numeric column to threshold (e.g. fico_score, dti). cutoffs : Candidate threshold values to evaluate. direction : "min" approves rows where cutoff_col >= cutoff (FICO floor); "max" approves rows where cutoff_col <= cutoff (DTI ceiling). weight_col : Optional loan-balance weight.

Returns

DataFrame with columns: cutoff, n_approved, n_total_mature, approval_rate, projected_event_rate, volume_loss_pct, loss_reduction_pct.

.. warning:: Selection bias: this analysis is backward-looking. You only observe performance for loans that were approved. The performance of declined loans is unknown (rejection inference problem). Use for directional analysis only — not for precise P&L projection.

Predictive Modeling

Use predictive.run() for the end-to-end path: target construction, training, scoring, and temporal out-of-time backtesting behind one call, returning a result with .summary() and .plot(). Use train_binary_model() and score_model() directly for narrow training and scoring steps, or the forecasting bridge to turn model scores into calendar-month portfolio projections.

cranalytics.predictive

Predictive modeling compatibility re-export surface.

The deep entry point for the predictive workflow is cranalytics.predictive.run (defined in :mod:cranalytics.predictive._session), which trains a binary model, scores it, and runs a temporal out-of-time backtest behind one call. Prefer it for end-to-end modeling; it returns a result with .summary() and .plot(). Reach for the narrower predictive.* helpers directly only when you need one concern such as training, scoring, or backtesting.

This module carries no logic of its own — it only re-exports the focused submodules (targets, modeling, backtest, forecasting bridge, session) so that existing from cranalytics.predictive import ... call sites keep working. Add new behavior to the appropriate submodule, not here.

PredictiveModelingSessionResult dataclass

summary() -> pd.DataFrame

Backtest metrics aggregated across folds (mean AUC/Gini/KS).

plot(**kwargs: Any) -> Any

Gini stability across backtest folds. Requires matplotlib.

run_predictive_backtest(df: pd.DataFrame, feature_cols: list[str], target_col: str, split_col: str, *, model_family: str, window_type: str = 'expanding', n_splits: int | np.integer = 5, rolling_window_size: int | np.integer | None = None, model_params: dict | None = None, random_state: int = 42, strict: bool = False, label_available_col: str | None = None, feature_as_of_col: str | None = None, target_horizon_months: int | np.integer | None = None, evaluation_as_of: object = None, loan_id_col: str | None = None, preprocessor: Any | None = None) -> pd.DataFrame

Rolling or expanding temporal OOT backtest.

Splits df by unique values of split_col (e.g., origination_month) in ascending order. No data shuffling — all splits are strictly temporal.

Parameters

df : pd.DataFrame feature_cols : list[str] target_col : str Binary target (0/1/NaN). NaN rows excluded from training and scoring. split_col : str Column defining temporal ordering (e.g., origination_month, vintage). model_family : str Passed to _build_binary_estimator. window_type : str "expanding" — all prior periods in train. "rolling" — fixed-width window of rolling_window_size prior periods. n_splits : int Number of OOT folds. Requires len(unique_periods) >= n_splits + 1. rolling_window_size : int, optional Number of prior periods in each rolling train window. Only used when window_type="rolling". Defaults to n_splits when not provided. model_params : dict, optional random_state : int label_available_col : str, optional Date when each training label became available. If supplied, both this column and split_col must contain dates. A fold excludes training rows whose labels became available after its validation-period start and reports the excluded count as n_purged. Missing availability dates on labeled rows are rejected. Without it, the caller must ensure labels were known at each historical cutoff.

str, optional

Feature snapshot date, no later than its split decision date. If absent, the split date is the assumed feature date; stored values must actually represent information known then.

target_horizon_months : int, optional Months after the split decision date. Derives conservative label availability when no explicit availability column is given; explicit negative labels cannot be available before this horizon ends. evaluation_as_of : date, optional Only evaluate validation outcomes available by this historical date. Requires explicit availability or a target horizon. loan_id_col : str, optional Origination-model identity; defaults to loan_id when present. Requires one non-null row per loan. Repeated-observation models are unsupported. preprocessor : sklearn transformer, optional Cloned and fit inside each training fold (including supervised selection using training labels). Accepts numeric arrays in feature_cols order.

Returns

pd.DataFrame One row per requested fold: split, train_periods, val_periods, n_train, n_val, n_purged, auc, gini, ks, status, reason. Skipped folds retain their counts and have NaN metrics. Strict mode rejects any skipped fold.

summarize_predictive_backtest(backtest_df: pd.DataFrame, *, by: str | list[str] | None = None) -> pd.DataFrame

Aggregate backtest metrics across folds.

Parameters

backtest_df : pd.DataFrame Output of run_predictive_backtest. by : str or list[str], optional Column(s) to group by (e.g., "model"). If None, aggregates all rows.

Returns

pd.DataFrame One row per group with mean_auc, mean_gini, mean_ks, n_folds, n_requested_folds, n_skipped_folds and status (ok, partial, or insufficient_data). Only evaluated folds contribute to metric means. Frames produced before fold statuses were added remain supported.

forecast_calendar_chargeoff_from_predictions(scored_df: pd.DataFrame, *, score_col: str, as_of_col: str, current_mob_col: str, hazard_curves: pd.DataFrame, principal_col: str = 'original_principal', segment_col: str | None = None, strict: bool = True, prediction_kind: str | None = None, prediction_horizon_months: int | str | None = None, exposure_basis: str | None = None, chargeoff_severity: float | None = None, loss_basis: str = 'gross_chargeoff') -> pd.DataFrame

Allocate compatible remaining-horizon predictions to monthly gross chargeoffs.

Predictions must explicitly be expected_chargeoff_fraction (expected future gross chargeoffs divided by the selected exposure) or default_probability. Default probabilities additionally require an explicit chargeoff_severity in [0, 1]: gross chargeoffs conditional on default divided by the selected exposure. Early-delinquency probabilities and net-loss fractions are incompatible and must be modeled or converted outside this function. No implicit PD-to-loss or delinquency-to-PD conversion is performed.

prediction_horizon_months is a positive integer or a column containing each loan's remaining horizon measured from its snapshot. Forecasts use exactly the same horizon; historical/cumulative lifetime scores must first be converted to remaining-horizon predictions. exposure_basis explicitly declares original_principal or current_balance for principal_col. The caller must ensure predictions and severity use that same denominator. Exposure is held fixed for this allocation; amortization is not modeled.

The timing profile in hazard_curves must contain one finite nonnegative rate per segment/MOB for every future month in each requested horizon, with a positive sum. Rates are normalized into timing weights, not interpreted as an independent default probability. Missing months, zero profiles, duplicate observations and invalid scores/exposures raise rather than drop expected dollars. strict=False allows an unweighted global mean profile for missing segments, with a warning and metadata identifying fallback use.

Returns a DataFrame with forecast_month, expected_chargeoff_amount, loan_count and expected_chargeoff_rate. The rate denominator is total input exposure in principal_col. Counts are loans contributing to each month, not default counts. Currency units match principal_col; recoveries are not deducted. attrs["prediction_contract"] records the semantic choices, exposure denominator and total expected/allocated dollars.

score_model(df: pd.DataFrame, estimator: Any, feature_cols: list[str], *, output_col: str, prediction_type: str) -> pd.DataFrame

Score a fitted estimator against a DataFrame.

Parameters:

Name Type Description Default
df DataFrame

DataFrame to score. It is the first argument for .pipe() compatibility.

required
estimator Any

Fitted scikit-learn-compatible estimator.

required
feature_cols list[str]

Columns passed to the estimator.

required
output_col str

Name for the appended prediction column.

required
prediction_type str

probability, class, or value.

required

Returns:

Type Description
DataFrame

Copy of df with output_col appended.

Examples:

>>> import pandas as pd
>>> from sklearn.linear_model import LogisticRegression
>>> from cranalytics.predictive import score_model
>>> train = pd.DataFrame({"fico": [600, 650, 750, 800], "bad": [1, 1, 0, 0]})
>>> estimator = LogisticRegression().fit(train[["fico"]].to_numpy(), train["bad"])
>>> scored = score_model(
...     train, estimator, ["fico"], output_col="pd", prediction_type="probability"
... )
>>> bool(scored["pd"].between(0, 1).all())
True

train_binary_model(df: pd.DataFrame, feature_cols: list[str], target_col: str, *, model_family: str, model_params: dict[str, Any] | None = None, calibrate: bool = False, random_state: int = 42, strict: bool = False, preprocessor: Any | None = None) -> tuple

Train a binary classification model.

Parameters:

Name Type Description Default
df DataFrame

Modeling frame.

required
feature_cols list[str]

Columns passed to the estimator.

required
target_col str

Binary 0/1 target. Null rows are dropped before training.

required
model_family str

logistic, hgb_classifier, or xgboost_classifier.

required
model_params dict[str, Any] | None

Optional parameters merged over estimator defaults.

None
calibrate bool

Reserved calibration switch. Currently raises when enabled.

False
random_state int

Seed forwarded to supported estimators.

42
strict bool

Whether predictive contract warnings should fail validation.

False
preprocessor Any | None

Optional sklearn-compatible transformer or pipeline, cloned and fitted only on training rows. Receives a numeric feature array; column selectors must use indices. Returned estimator retains it for scoring. Do not pre-fit preprocessing on the full backtest dataset.

None

Returns:

Type Description
tuple

Tuple of fitted estimator, metadata dictionary, and diagnostics

tuple

DataFrame.

Raises:

Type Description
NotImplementedError

If calibrate=True.

ValueError

If the modeling frame or family is invalid.

Examples:

>>> import pandas as pd
>>> from cranalytics.predictive import train_binary_model
>>> frame = pd.DataFrame({"fico": [600, 650, 750, 800], "bad": [1, 1, 0, 0]})
>>> estimator, metadata, diagnostics = train_binary_model(
...     frame, ["fico"], "bad", model_family="logistic"
... )
>>> metadata["model_family"]
'logistic'
>>> diagnostics["split"].tolist()
['train']

train_regression_model(df: pd.DataFrame, feature_cols: list[str], target_col: str, *, model_family: str, model_params: dict[str, Any] | None = None, random_state: int = 42, strict: bool = False, preprocessor: Any | None = None) -> tuple

Train a regression model.

Parameters

model_family : str "tweedie", "hgb_regressor", "quantile_hgb", "xgboost_regressor".

Returns

tuple[estimator, dict, pd.DataFrame]

run(df: pd.DataFrame, feature_cols: list[str], target_col: str, split_col: str, *, model_family: str, model_params: dict | None = None, window_type: str = 'expanding', n_splits: int | np.integer = 5, rolling_window_size: int | np.integer | None = None, score_output_col: str = 'prediction', scoring_df: pd.DataFrame | None = None, random_state: int = 42, strict: bool = False, label_available_col: str | None = None, feature_as_of_col: str | None = None, target_horizon_months: int | np.integer | None = None, evaluation_as_of: object = None, training_as_of: object = None, loan_id_col: str | None = None, preprocessor: Any | None = None) -> PredictiveModelingSessionResult

Run one end-to-end predictive modeling session.

The representative session trains a binary model on df, scores either df or scoring_df, executes a temporal out-of-time backtest on df, and returns a typed session result with fold-level and summarized outputs.

Pass label_available_col to purge labels unavailable at each historical backtest cutoff (requires date-valued split_col), or supply target_horizon_months to derive conservative availability. Feature snapshots must predate their split decision dates. evaluation_as_of limits validation outcomes; training_as_of limits the final fit to decisions and labels known on that date. With no final cutoff the final estimator fits the supplied frame, independently of historical backtests. preprocessor is cloned separately for each fold and the final fit. Loan IDs, when present or explicitly selected, must be unique.

assemble_modeling_frame(features_df: pd.DataFrame, targets_df: pd.DataFrame, *, on: str | list[str], leakage_guard: bool = True) -> pd.DataFrame

Join features and targets with optional leakage detection.

Parameters

features_df : pd.DataFrame targets_df : pd.DataFrame on : str or list[str] Join key(s), typically "loan_id" or ["loan_id", "as_of_date"]. leakage_guard : bool If True, raises if any non-key target column appears in features_df.

Returns

pd.DataFrame Inner join of features and targets.

build_targets(panel_df: pd.DataFrame, *, mode: str, targets: list[str], target_overrides: dict | None = None, include_diagnostics: bool = False) -> pd.DataFrame

Derive or validate binary/regression targets from a monthly performance panel.

Parameters

panel_df : pd.DataFrame Monthly performance panel. Required columns (panel mode): loan_id, mob, as_of_date, origination_date, original_principal, dpd, chargeoff_amount. mode : str "panel" — derive targets from dpd/chargeoff columns. "prelabeled" — validate and pass through pre-built target columns. targets : list[str] Target column names to derive or validate. target_overrides : dict, optional Per-target config overrides, e.g. {"fpf30_flag": {"dpd_threshold": 60, "mob_horizon": 6}}.

bool

Append per-target __status, __missing_mobs, __n_observed, __n_required and __label_available_date columns in panel mode. The same records are always available in attrs["target_diagnostics"].

Returns

pd.DataFrame Loan-level DataFrame with loan_id + one column per target. NaN means unknown: immature, incomplete coverage, unavailable outcome, invalid denominator or no confirmed lifetime completion.

Notes

MOB is an integer monthly age. Window targets require every MOB from 1 to the horizon; MOB 0 is optional and excluded. Point-in-time flags require only their specified MOB. Missing DPD/chargeoffs do not count as observed. An observed early delinquency establishes a positive even in an incomplete window. No terminal status implicitly fills future months or proves a fixed-horizon negative. Lifetime chargeoff additionally requires a boolean terminal_col override: True certifies that the final gross chargeoff is known, including any post-closure adjustments. min_mob alone is not evidence of lifetime completion. Chargeoffs must be monthly increments, not cumulative amounts; percentage denominators are original principal.

Prediction-to-chargeoff contract

forecast_calendar_chargeoff_from_predictions now requires an explicit prediction kind, residual horizon and exposure basis. Earlier calls that supplied only a score are rejected: multiplying a first-payment delinquency probability by principal is not an estimate of gross chargeoffs.

Prediction kind Expected future gross chargeoffs
expected_chargeoff_fraction Score × the declared exposure denominator. The score already includes severity.
default_probability PD × explicit chargeoff_severity × the declared exposure denominator.
Early-delinquency probability, net-loss fraction Rejected. A separate fitted conversion/recovery model is required.

The prediction must describe the remaining prediction_horizon_months from each row's snapshot. Supply a positive integer for a common horizon or a column name for per-loan horizons. A cumulative lifetime score, a three-month early flag, and a twelve-month default score are not interchangeable. The bridge cannot verify how a supplied score was trained; these arguments declare a contract that the analyst must establish from the target definition and model validation.

exposure_basis is original_principal or current_balance; principal_col contains that denominator. Both the prediction and conditional severity must use the same basis. Gross chargeoff severity is before recoveries, unlike many net LGD estimates. Only loss_basis="gross_chargeoff" is supported. The allocation holds exposure fixed; it does not model amortization, prepayment or future balance changes.

import pandas as pd
from cranalytics.predictive import forecast_calendar_chargeoff_from_predictions

scored = pd.DataFrame({
    "loan_id": ["A"], "pd_2m": [0.20], "balance": [600.0],
    "snapshot": pd.to_datetime(["2024-06-15"]), "mob": [2],
})
profile = pd.DataFrame({
    "segment_id": ["all", "all"], "month_on_book": [3, 4],
    "chargeoff_hazard_rate": [0.2, 0.3],
})
forecast = forecast_calendar_chargeoff_from_predictions(
    scored, score_col="pd_2m", as_of_col="snapshot", current_mob_col="mob",
    hazard_curves=profile, principal_col="balance",
    prediction_kind="default_probability", prediction_horizon_months=2,
    exposure_basis="current_balance", chargeoff_severity=0.5,
)
# 0.20 * 0.50 * 600 = 60, allocated 40%/60% over the next two months.
assert abs(forecast["expected_chargeoff_amount"].sum() - 60.0) < 1e-9
assert forecast["expected_chargeoff_amount"].tolist() == [24.0, 36.0]
print(forecast.attrs["prediction_contract"])

The chargeoff hazard profile supplies timing weights, normalized over exactly the prediction horizon; it does not supply another default probability. Every requested month must be present, and the profile must have positive total weight. Missing months, zero-weight profiles, duplicate segment/MOB rows and invalid numeric inputs raise. A loan outside the profile horizon is rejected instead of silently losing its expected dollars, even when all loans would be skipped. strict=False still permits a missing-segment fallback to the unweighted mean profile across segments, with a warning and the affected segments in metadata.

Output amounts are in the exposure's currency units. Each monthly rate divides by total input exposure in principal_col. loan_count counts contributing loans per month, not predicted defaults. attrs["prediction_contract"] records the prediction type, horizon declaration, denominator, severity, gross-loss basis, fallback use and expected/allocated totals. Export it separately from CSV.