gitlabds 2.2.2__tar.gz → 2.2.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitlabds-2.2.2 → gitlabds-2.2.3}/PKG-INFO +31 -14
- {gitlabds-2.2.2 → gitlabds-2.2.3}/README.md +30 -13
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/record_benchmarking.py +234 -53
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/PKG-INFO +31 -14
- {gitlabds-2.2.2 → gitlabds-2.2.3}/LICENSE +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/__init__.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/baselines.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/config_generator.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/dummy.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/feature_descriptions.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/feature_reduction.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/insights.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/memory_optimization.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/missing.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/model_evaluator.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/monitoring_metrics.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/outliers.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/serving_features.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/split_data.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/trends.py +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/SOURCES.txt +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/dependency_links.txt +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/requires.txt +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/top_level.txt +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/pyproject.toml +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/setup.cfg +0 -0
- {gitlabds-2.2.2 → gitlabds-2.2.3}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlabds
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: GitLab Data Science Tools
|
|
5
5
|
Author-email: Kevin Dietz <kdietz@gitlab.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -827,10 +827,10 @@ Status label for a value vs. its peer median (+/- tolerance), not percentile ran
|
|
|
827
827
|
#### Parameters:
|
|
828
828
|
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
829
829
|
- _**peer_median**_ : peer group median(s) to compare against
|
|
830
|
-
- _**good_direction**_ : `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better).
|
|
830
|
+
- _**good_direction**_ : one of `"increasing"`, `"decreasing"`, `"both"`, `"none"` (scalar or array-like). `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better). `"both"` and `"none"` mean the feature has no single good direction, so the output is positional only: no flip, and the `ceiling_label` upgrade is suppressed. `None` (the default) is treated as `"none"`. Any other value raises — a blank/NaN in a feature dictionary must be normalized to `"none"` first rather than silently defaulting to increasing.
|
|
831
831
|
- _**tolerance**_ : relative distance from the peer median (as a fraction of the median) required to be classified above/below average rather than "average". Default is 0.15 (a value must be >15% above/below its peer median to leave the "average" band)
|
|
832
832
|
- _**no_activity_value**_ : value that should always map to `labels[3]` ("No activity") regardless of tolerance
|
|
833
|
-
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`
|
|
833
|
+
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`. Only applies when `good_direction` is `"increasing"` or `"decreasing"`
|
|
834
834
|
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
835
835
|
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
836
836
|
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
@@ -859,7 +859,7 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
859
859
|
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
860
860
|
|
|
861
861
|
#### Parameters:
|
|
862
|
-
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
862
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost). A raw `xgboost.Booster` (from `xgb.train`, or loaded back from a saved model file) works as well as the sklearn wrapper
|
|
863
863
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
864
864
|
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
865
865
|
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
@@ -870,6 +870,8 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
870
870
|
#### Returns
|
|
871
871
|
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
872
872
|
|
|
873
|
+
`direction` ("increases"/"decreases") describes the effect on the model's predicted probability of class 1, not on the business outcome. Where class 1 is a bad outcome (e.g. churn), "increases" means worse — any narrative or scorecard layer reading this column has to apply that polarity itself.
|
|
874
|
+
|
|
873
875
|
#### Requirements
|
|
874
876
|
This function requires the `shap` package (included in core dependencies).
|
|
875
877
|
|
|
@@ -889,27 +891,29 @@ shap_df = gitlabds.per_record_shap(
|
|
|
889
891
|
<details><summary> Per-Record Counterfactuals </summary>
|
|
890
892
|
|
|
891
893
|
#### Description
|
|
892
|
-
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes the
|
|
894
|
+
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes `gain` — the improvement in the model score, signed by `score_goal` so that bigger is always better. No monotonicity assumption is made.
|
|
893
895
|
|
|
894
|
-
`gitlabds.per_record_counterfactuals(model, df, shap_df,
|
|
896
|
+
`gitlabds.per_record_counterfactuals(model, df, shap_df, unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, score_goal="maximize", actionable_values=["Yes", "Partial"], min_gain=0.005, impact_medium=0.01, impact_high=0.03, levels={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
895
897
|
|
|
896
898
|
#### Parameters:
|
|
897
|
-
- _**model**_ : trained model (as in `per_record_shap`)
|
|
899
|
+
- _**model**_ : trained model (as in `per_record_shap`). How probabilities are scored is chosen from the model object itself — `.predict_proba()` where available, otherwise a raw `xgboost.Booster` or `lightgbm.Booster` is scored natively. An unsupported type raises a `TypeError` naming it
|
|
898
900
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
899
|
-
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test
|
|
900
|
-
- _**model_algo**_ : `"lgb"` or `"xgb"`/`"xgboost"`; controls how probabilities are scored
|
|
901
|
+
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test. Only numeric non-bool levers are evaluated — candidate values are peer-group quantiles, which need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning`. Marking them `'none'` in `direction` is the explicit way to express the same thing, and suppresses the warning
|
|
901
902
|
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
902
903
|
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
903
904
|
- _**candidate_quantiles**_ : peer-group quantiles evaluated as candidate values
|
|
904
|
-
- _**direction**_ : optional per-feature
|
|
905
|
+
- _**direction**_ : optional per-feature constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` | `'none'` (dict or Series). `'increasing'`/`'decreasing'` restrict candidates to moves in that direction (an achievability constraint); `'both'` allows either; `'none'` excludes the feature from lever evaluation entirely, for features with no meaningful good direction (categoricals, identifiers, context-only numerics). Features absent from the mapping, or `direction=None`, default to `'both'`. A blank/NaN raises — normalize it to `'none'` where the feature dictionary is read
|
|
906
|
+
- _**score_goal**_ : `"maximize"` if a higher predicted probability of class 1 is the good outcome (e.g. class 1 = uptier), `"minimize"` if it is the bad one (e.g. class 1 = churn). Sets the sign of `gain`, which drives best-move selection, the `min_gain` filter, `impact_bucket`, and the output ordering. This is the outcome's polarity only — `direction` above is a separate, feature-level concept and is unaffected. Default is `"maximize"`
|
|
905
907
|
- _**actionable_values**_ : values in `shap_df["actionable"]` treated as actionable levers
|
|
906
|
-
- _**
|
|
907
|
-
- _**impact_medium, impact_high**_ :
|
|
908
|
-
- _**levels**_ : dict mapping label -> upper bound on
|
|
908
|
+
- _**min_gain**_ : drop levers whose best achievable gain is below this. Note this is a threshold on `gain`, not on raw `delta_p`: under `score_goal="minimize"` a good move has a negative `delta_p` but a positive `gain`, so this stays positive for both polarities. Negative values (or `-inf`) are allowed, and keep moves that make the outcome worse — useful for inspecting the full candidate distribution while tuning thresholds
|
|
909
|
+
- _**impact_medium, impact_high**_ : `gain` thresholds bucketing each lever into Low/Medium/High `impact_bucket`. Only meaningful for `gain > 0` — under a negative `min_gain` the retained harmful moves stay `"Low"` (the level-crossing floor doesn't apply to them), so read `gain`'s sign rather than the bucket alone
|
|
910
|
+
- _**levels**_ : dict mapping label -> upper bound on P(class 1) for that level (e.g., default `{"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}` means `p <= 0.03` -> "poor fit", `0.03 < p <= 0.10` -> "good fit", `p > 0.10` -> "best fit"). Key order doesn't matter — levels are sorted by cutoff and labels follow that order — but the top level's cutoff must be `float("inf")`. Cutoffs are always in P(class 1) space regardless of `score_goal`, and `crosses_level` is a plain inequality, so name the labels for your own outcome: a churn model would pass something like `{"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")}`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact)
|
|
909
911
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
910
912
|
|
|
911
913
|
#### Returns
|
|
912
|
-
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
914
|
+
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `gain`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
915
|
+
|
|
916
|
+
`delta_p` is the raw signed change in P(class 1); `gain` is that change signed by `score_goal` so bigger is always better. For a `score_goal="minimize"` model, `delta_p` will be negative and `gain` positive.
|
|
913
917
|
|
|
914
918
|
#### Requirements
|
|
915
919
|
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
@@ -924,6 +928,17 @@ counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
|
924
928
|
shap_df=shap_df,
|
|
925
929
|
peer_group=peer_group_df,
|
|
926
930
|
)
|
|
931
|
+
|
|
932
|
+
# Churn model: class 1 = churn, so a lower score is the good outcome.
|
|
933
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
934
|
+
model=churn_model,
|
|
935
|
+
df=model_features,
|
|
936
|
+
shap_df=shap_df,
|
|
937
|
+
peer_group=peer_group_df,
|
|
938
|
+
score_goal="minimize",
|
|
939
|
+
direction=dict(zip(feature_dict["field"], feature_dict["good_direction"])),
|
|
940
|
+
levels={"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")},
|
|
941
|
+
)
|
|
927
942
|
```
|
|
928
943
|
|
|
929
944
|
</details>
|
|
@@ -944,6 +959,8 @@ Peer benchmarking. Each record's percentile for each feature within its peer gro
|
|
|
944
959
|
- _**features**_ : optional explicit list of features to benchmark (used as-is, no actionable filter). If `None` (default), computed for every actionable feature in `df`
|
|
945
960
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
946
961
|
|
|
962
|
+
Either way, only numeric non-bool columns are benchmarked — percentile ranks and quantiles need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning` naming them.
|
|
963
|
+
|
|
947
964
|
#### Returns
|
|
948
965
|
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
949
966
|
|
|
@@ -795,10 +795,10 @@ Status label for a value vs. its peer median (+/- tolerance), not percentile ran
|
|
|
795
795
|
#### Parameters:
|
|
796
796
|
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
797
797
|
- _**peer_median**_ : peer group median(s) to compare against
|
|
798
|
-
- _**good_direction**_ : `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better).
|
|
798
|
+
- _**good_direction**_ : one of `"increasing"`, `"decreasing"`, `"both"`, `"none"` (scalar or array-like). `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better). `"both"` and `"none"` mean the feature has no single good direction, so the output is positional only: no flip, and the `ceiling_label` upgrade is suppressed. `None` (the default) is treated as `"none"`. Any other value raises — a blank/NaN in a feature dictionary must be normalized to `"none"` first rather than silently defaulting to increasing.
|
|
799
799
|
- _**tolerance**_ : relative distance from the peer median (as a fraction of the median) required to be classified above/below average rather than "average". Default is 0.15 (a value must be >15% above/below its peer median to leave the "average" band)
|
|
800
800
|
- _**no_activity_value**_ : value that should always map to `labels[3]` ("No activity") regardless of tolerance
|
|
801
|
-
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`
|
|
801
|
+
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`. Only applies when `good_direction` is `"increasing"` or `"decreasing"`
|
|
802
802
|
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
803
803
|
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
804
804
|
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
@@ -827,7 +827,7 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
827
827
|
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
828
828
|
|
|
829
829
|
#### Parameters:
|
|
830
|
-
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
830
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost). A raw `xgboost.Booster` (from `xgb.train`, or loaded back from a saved model file) works as well as the sklearn wrapper
|
|
831
831
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
832
832
|
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
833
833
|
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
@@ -838,6 +838,8 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
838
838
|
#### Returns
|
|
839
839
|
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
840
840
|
|
|
841
|
+
`direction` ("increases"/"decreases") describes the effect on the model's predicted probability of class 1, not on the business outcome. Where class 1 is a bad outcome (e.g. churn), "increases" means worse — any narrative or scorecard layer reading this column has to apply that polarity itself.
|
|
842
|
+
|
|
841
843
|
#### Requirements
|
|
842
844
|
This function requires the `shap` package (included in core dependencies).
|
|
843
845
|
|
|
@@ -857,27 +859,29 @@ shap_df = gitlabds.per_record_shap(
|
|
|
857
859
|
<details><summary> Per-Record Counterfactuals </summary>
|
|
858
860
|
|
|
859
861
|
#### Description
|
|
860
|
-
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes the
|
|
862
|
+
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes `gain` — the improvement in the model score, signed by `score_goal` so that bigger is always better. No monotonicity assumption is made.
|
|
861
863
|
|
|
862
|
-
`gitlabds.per_record_counterfactuals(model, df, shap_df,
|
|
864
|
+
`gitlabds.per_record_counterfactuals(model, df, shap_df, unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, score_goal="maximize", actionable_values=["Yes", "Partial"], min_gain=0.005, impact_medium=0.01, impact_high=0.03, levels={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
863
865
|
|
|
864
866
|
#### Parameters:
|
|
865
|
-
- _**model**_ : trained model (as in `per_record_shap`)
|
|
867
|
+
- _**model**_ : trained model (as in `per_record_shap`). How probabilities are scored is chosen from the model object itself — `.predict_proba()` where available, otherwise a raw `xgboost.Booster` or `lightgbm.Booster` is scored natively. An unsupported type raises a `TypeError` naming it
|
|
866
868
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
867
|
-
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test
|
|
868
|
-
- _**model_algo**_ : `"lgb"` or `"xgb"`/`"xgboost"`; controls how probabilities are scored
|
|
869
|
+
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test. Only numeric non-bool levers are evaluated — candidate values are peer-group quantiles, which need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning`. Marking them `'none'` in `direction` is the explicit way to express the same thing, and suppresses the warning
|
|
869
870
|
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
870
871
|
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
871
872
|
- _**candidate_quantiles**_ : peer-group quantiles evaluated as candidate values
|
|
872
|
-
- _**direction**_ : optional per-feature
|
|
873
|
+
- _**direction**_ : optional per-feature constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` | `'none'` (dict or Series). `'increasing'`/`'decreasing'` restrict candidates to moves in that direction (an achievability constraint); `'both'` allows either; `'none'` excludes the feature from lever evaluation entirely, for features with no meaningful good direction (categoricals, identifiers, context-only numerics). Features absent from the mapping, or `direction=None`, default to `'both'`. A blank/NaN raises — normalize it to `'none'` where the feature dictionary is read
|
|
874
|
+
- _**score_goal**_ : `"maximize"` if a higher predicted probability of class 1 is the good outcome (e.g. class 1 = uptier), `"minimize"` if it is the bad one (e.g. class 1 = churn). Sets the sign of `gain`, which drives best-move selection, the `min_gain` filter, `impact_bucket`, and the output ordering. This is the outcome's polarity only — `direction` above is a separate, feature-level concept and is unaffected. Default is `"maximize"`
|
|
873
875
|
- _**actionable_values**_ : values in `shap_df["actionable"]` treated as actionable levers
|
|
874
|
-
- _**
|
|
875
|
-
- _**impact_medium, impact_high**_ :
|
|
876
|
-
- _**levels**_ : dict mapping label -> upper bound on
|
|
876
|
+
- _**min_gain**_ : drop levers whose best achievable gain is below this. Note this is a threshold on `gain`, not on raw `delta_p`: under `score_goal="minimize"` a good move has a negative `delta_p` but a positive `gain`, so this stays positive for both polarities. Negative values (or `-inf`) are allowed, and keep moves that make the outcome worse — useful for inspecting the full candidate distribution while tuning thresholds
|
|
877
|
+
- _**impact_medium, impact_high**_ : `gain` thresholds bucketing each lever into Low/Medium/High `impact_bucket`. Only meaningful for `gain > 0` — under a negative `min_gain` the retained harmful moves stay `"Low"` (the level-crossing floor doesn't apply to them), so read `gain`'s sign rather than the bucket alone
|
|
878
|
+
- _**levels**_ : dict mapping label -> upper bound on P(class 1) for that level (e.g., default `{"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}` means `p <= 0.03` -> "poor fit", `0.03 < p <= 0.10` -> "good fit", `p > 0.10` -> "best fit"). Key order doesn't matter — levels are sorted by cutoff and labels follow that order — but the top level's cutoff must be `float("inf")`. Cutoffs are always in P(class 1) space regardless of `score_goal`, and `crosses_level` is a plain inequality, so name the labels for your own outcome: a churn model would pass something like `{"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")}`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact)
|
|
877
879
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
878
880
|
|
|
879
881
|
#### Returns
|
|
880
|
-
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
882
|
+
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `gain`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
883
|
+
|
|
884
|
+
`delta_p` is the raw signed change in P(class 1); `gain` is that change signed by `score_goal` so bigger is always better. For a `score_goal="minimize"` model, `delta_p` will be negative and `gain` positive.
|
|
881
885
|
|
|
882
886
|
#### Requirements
|
|
883
887
|
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
@@ -892,6 +896,17 @@ counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
|
892
896
|
shap_df=shap_df,
|
|
893
897
|
peer_group=peer_group_df,
|
|
894
898
|
)
|
|
899
|
+
|
|
900
|
+
# Churn model: class 1 = churn, so a lower score is the good outcome.
|
|
901
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
902
|
+
model=churn_model,
|
|
903
|
+
df=model_features,
|
|
904
|
+
shap_df=shap_df,
|
|
905
|
+
peer_group=peer_group_df,
|
|
906
|
+
score_goal="minimize",
|
|
907
|
+
direction=dict(zip(feature_dict["field"], feature_dict["good_direction"])),
|
|
908
|
+
levels={"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")},
|
|
909
|
+
)
|
|
895
910
|
```
|
|
896
911
|
|
|
897
912
|
</details>
|
|
@@ -912,6 +927,8 @@ Peer benchmarking. Each record's percentile for each feature within its peer gro
|
|
|
912
927
|
- _**features**_ : optional explicit list of features to benchmark (used as-is, no actionable filter). If `None` (default), computed for every actionable feature in `df`
|
|
913
928
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
914
929
|
|
|
930
|
+
Either way, only numeric non-bool columns are benchmarked — percentile ranks and quantiles need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning` naming them.
|
|
931
|
+
|
|
915
932
|
#### Returns
|
|
916
933
|
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
917
934
|
|
|
@@ -11,10 +11,107 @@ whichever column identifies a row in your data. Includes:
|
|
|
11
11
|
- per_record_peer_percentiles : per-record percentile standing within a peer group
|
|
12
12
|
"""
|
|
13
13
|
|
|
14
|
+
import warnings
|
|
15
|
+
|
|
14
16
|
import pandas as pd
|
|
15
17
|
import numpy as np
|
|
16
18
|
|
|
17
19
|
|
|
20
|
+
# The four accepted good_direction values, plus the shorthands the feature dictionaries
|
|
21
|
+
# use in practice. Anything else raises -- a blank/NaN or a typo must not silently fall
|
|
22
|
+
# through to a default, since "increasing" and "both" are very different assumptions.
|
|
23
|
+
_DIRECTION_ALIASES = {
|
|
24
|
+
"increasing": "increasing", "increase": "increasing", "up": "increasing",
|
|
25
|
+
"decreasing": "decreasing", "decrease": "decreasing", "down": "decreasing",
|
|
26
|
+
"both": "both",
|
|
27
|
+
"none": "none",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _norm_direction(value, context=""):
|
|
32
|
+
"""
|
|
33
|
+
Normalize one good_direction value to 'increasing', 'decreasing', 'both', or 'none'.
|
|
34
|
+
|
|
35
|
+
Raises ValueError on anything unrecognized, including blank/NaN. Normalize blanks to
|
|
36
|
+
the explicit string "none" where the feature dictionary is read, not here -- a blank
|
|
37
|
+
means "nobody decided yet", which is a data problem, not a behavior to default.
|
|
38
|
+
|
|
39
|
+
Python None raises too, even though str(None) would spell "none": pandas reads an empty
|
|
40
|
+
cell as None or NaN depending on dtype, so accepting it would put the silent default
|
|
41
|
+
straight back in.
|
|
42
|
+
"""
|
|
43
|
+
key = "" if value is None else str(value).strip().lower()
|
|
44
|
+
if key not in _DIRECTION_ALIASES:
|
|
45
|
+
raise ValueError(
|
|
46
|
+
f"{context}unrecognized direction {value!r}. Accepted values are 'increasing', "
|
|
47
|
+
"'decreasing', 'both', and 'none'. Blank/NaN values must be normalized to "
|
|
48
|
+
"'none' before calling."
|
|
49
|
+
)
|
|
50
|
+
return _DIRECTION_ALIASES[key]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _peer_quantile_eligible(df, cols, context, hint):
|
|
54
|
+
"""
|
|
55
|
+
Split `cols` into those that support peer quantiles/percentile ranks and those that don't,
|
|
56
|
+
warning about the latter. Returns the eligible subset, order preserved.
|
|
57
|
+
|
|
58
|
+
Only genuinely numeric, non-bool columns qualify. The rest either raise or, worse, return
|
|
59
|
+
something misleading:
|
|
60
|
+
|
|
61
|
+
- unordered categorical : groupby.rank raises "Cannot perform rank with non-ordered
|
|
62
|
+
Categorical"
|
|
63
|
+
- bool : quantile raises "numpy boolean subtract ... is not supported",
|
|
64
|
+
even though pandas reports bool as a numeric dtype
|
|
65
|
+
- object / string : quantile raises on the interpolation subtraction
|
|
66
|
+
- ordered categorical : both succeed, but quantile returns the integer *category code*
|
|
67
|
+
(1.0 for the middle of three levels), so a p25/p50/p75 sitting next to a label-valued
|
|
68
|
+
record_value would be quietly nonsensical
|
|
69
|
+
"""
|
|
70
|
+
eligible = [f for f in cols
|
|
71
|
+
if pd.api.types.is_numeric_dtype(df[f]) and not pd.api.types.is_bool_dtype(df[f])]
|
|
72
|
+
dropped = [f for f in cols if f not in set(eligible)]
|
|
73
|
+
if dropped:
|
|
74
|
+
warnings.warn(
|
|
75
|
+
f"{context} skipped {sorted(dropped)}: peer quantiles and percentile ranks need an "
|
|
76
|
+
f"ordered numeric scale, which these dtypes don't provide. {hint}",
|
|
77
|
+
UserWarning,
|
|
78
|
+
stacklevel=3,
|
|
79
|
+
)
|
|
80
|
+
return eligible
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _binary_proba_fn(model):
|
|
84
|
+
"""
|
|
85
|
+
Return a callable mapping a feature DataFrame to P(class 1).
|
|
86
|
+
|
|
87
|
+
Dispatches on the model object rather than a caller-supplied algorithm name: a name can
|
|
88
|
+
disagree with the object it describes (and silently pick the wrong branch), while an
|
|
89
|
+
object cannot disagree with itself. Handles the sklearn wrappers for either library plus
|
|
90
|
+
raw xgboost and LightGBM boosters, which have no .predict_proba().
|
|
91
|
+
"""
|
|
92
|
+
if hasattr(model, "predict_proba"): # XGBClassifier / LGBMClassifier / sklearn
|
|
93
|
+
return lambda X: np.asarray(model.predict_proba(X))[:, 1]
|
|
94
|
+
try:
|
|
95
|
+
import xgboost as xgb
|
|
96
|
+
except ImportError:
|
|
97
|
+
pass
|
|
98
|
+
else:
|
|
99
|
+
if isinstance(model, xgb.Booster):
|
|
100
|
+
# enable_categorical is required for category dtypes and harmless without them.
|
|
101
|
+
return lambda X: np.asarray(model.predict(xgb.DMatrix(X, enable_categorical=True)))
|
|
102
|
+
try:
|
|
103
|
+
import lightgbm as lgb
|
|
104
|
+
except ImportError:
|
|
105
|
+
pass
|
|
106
|
+
else:
|
|
107
|
+
if isinstance(model, lgb.Booster): # binary objective already returns P(class 1)
|
|
108
|
+
return lambda X: np.asarray(model.predict(X))
|
|
109
|
+
raise TypeError(
|
|
110
|
+
f"cannot score model of type {type(model).__module__}.{type(model).__name__}. Expected "
|
|
111
|
+
"a model exposing .predict_proba(), an xgboost.Booster, or a lightgbm.Booster."
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
18
115
|
def value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15,
|
|
19
116
|
no_activity_value=None, peer_percentile=None, ceiling_pct=0.95,
|
|
20
117
|
ceiling_label="near ceiling",
|
|
@@ -24,9 +121,14 @@ def value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15
|
|
|
24
121
|
|
|
25
122
|
value : value(s) to evaluate, scalar or array-like.
|
|
26
123
|
peer_median : peer group median(s) to compare against; broadcasts against value.
|
|
27
|
-
good_direction :
|
|
28
|
-
|
|
29
|
-
|
|
124
|
+
good_direction : one of "increasing", "decreasing", "both", "none" (scalar or
|
|
125
|
+
array-like, broadcast against value). "decreasing" flips which side of the median
|
|
126
|
+
counts as "above" (e.g., for a metric where lower is better). "both" and "none"
|
|
127
|
+
mean the feature has no single good direction, so the output is positional only:
|
|
128
|
+
no flip, and the ceiling_label upgrade below is suppressed since "near ceiling"
|
|
129
|
+
is a goodness judgment. None (the default) is treated as "none". Any other value
|
|
130
|
+
raises -- a blank/NaN in a feature dictionary must be normalized to "none" first
|
|
131
|
+
rather than silently defaulting to increasing.
|
|
30
132
|
tolerance : relative distance from the peer median (as a fraction of the median)
|
|
31
133
|
required to be classified above/below average rather than "average". E.g.
|
|
32
134
|
the default 0.15 means a value must be >15% above (or below) its peer median
|
|
@@ -48,8 +150,14 @@ def value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15
|
|
|
48
150
|
value = pd.Series(value)
|
|
49
151
|
# index=value.index (not .reindex()) so a bare scalar broadcasts correctly.
|
|
50
152
|
peer_median = pd.Series(peer_median, index=value.index)
|
|
51
|
-
|
|
52
|
-
|
|
153
|
+
if good_direction is None:
|
|
154
|
+
norm = pd.Series("none", index=value.index)
|
|
155
|
+
else:
|
|
156
|
+
norm = pd.Series(good_direction, index=value.index).map(
|
|
157
|
+
lambda v: _norm_direction(v, "good_direction: "))
|
|
158
|
+
decreasing = norm.eq("decreasing")
|
|
159
|
+
# "increasing"/"decreasing" carry a good/bad judgment; "both"/"none" don't.
|
|
160
|
+
judged = norm.isin(("increasing", "decreasing"))
|
|
53
161
|
eff_value = value.where(~decreasing, -value)
|
|
54
162
|
eff_median = peer_median.where(~decreasing, -peer_median)
|
|
55
163
|
denom = eff_median.abs()
|
|
@@ -62,11 +170,13 @@ def value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15
|
|
|
62
170
|
# "near ceiling": already above the peer median AND in the top ceiling_pct of the peer
|
|
63
171
|
# group toward good_direction. Gating on labels[2] (above average) means a compressed
|
|
64
172
|
# peer group whose top decile is still ~median is left "average", not falsely capped.
|
|
173
|
+
# Skipped where good_direction is "both"/"none": with no single good direction there is
|
|
174
|
+
# no strength to be tapped out in.
|
|
65
175
|
# Applied before the null / no-activity masks below so those still take precedence.
|
|
66
176
|
if peer_percentile is not None:
|
|
67
177
|
pct = pd.Series(peer_percentile, index=value.index).astype(float)
|
|
68
178
|
eff_pct = pct.where(~decreasing, 1 - pct) # percentile toward the good direction
|
|
69
|
-
level = level.mask((eff_pct >= ceiling_pct) & (level == labels[2]), ceiling_label)
|
|
179
|
+
level = level.mask((eff_pct >= ceiling_pct) & (level == labels[2]) & judged, ceiling_label)
|
|
70
180
|
level = level.mask(value.isna(), labels[3])
|
|
71
181
|
if no_activity_value is not None:
|
|
72
182
|
no_activity_value = pd.Series(no_activity_value, index=value.index)
|
|
@@ -92,7 +202,9 @@ def per_record_shap(
|
|
|
92
202
|
the drivers.
|
|
93
203
|
|
|
94
204
|
model : trained tree-based model (e.g., LightGBM, XGBoost). Its underlying booster
|
|
95
|
-
is used directly if the model exposes one (e.g. `model.booster_`).
|
|
205
|
+
is used directly if the model exposes one (e.g. `model.booster_`). A raw
|
|
206
|
+
`xgboost.Booster` (trained via `xgb.train`, or loaded back from a saved model file)
|
|
207
|
+
works as well as the sklearn wrapper -- see the categorical note below.
|
|
96
208
|
df : model-ready feature matrix, indexed by unique_id.
|
|
97
209
|
feature_dict : DataFrame, or path to a CSV, with 'field' and 'actionable' columns
|
|
98
210
|
mapping each feature to whether/how it can be acted on.
|
|
@@ -104,6 +216,18 @@ def per_record_shap(
|
|
|
104
216
|
|
|
105
217
|
Returns a long DataFrame, one row per (record, feature): unique_id, rank, feature,
|
|
106
218
|
contribution, direction, feature_value, actionable.
|
|
219
|
+
|
|
220
|
+
Note on `direction`: "increases"/"decreases" describes the effect on the model's
|
|
221
|
+
predicted probability of class 1, not on the business outcome. Where class 1 is a bad
|
|
222
|
+
outcome (e.g. churn), "increases" means worse. Any narrative or scorecard layer reading
|
|
223
|
+
this column has to apply that polarity itself -- see `score_goal` in
|
|
224
|
+
per_record_counterfactuals for the equivalent handling there.
|
|
225
|
+
|
|
226
|
+
Note on category dtypes with a raw xgboost.Booster: shap builds its own DMatrix from df,
|
|
227
|
+
and only knows to pass enable_categorical=True when it can read that flag off an xgboost
|
|
228
|
+
*sklearn* wrapper. Given a raw Booster it has nowhere to read it from, so we build the
|
|
229
|
+
DMatrix here instead. Without this, a df with any `category` column raises "DataFrame.dtypes
|
|
230
|
+
for data must be int, float, bool or category".
|
|
107
231
|
"""
|
|
108
232
|
import shap, warnings
|
|
109
233
|
if isinstance(feature_dict, str):
|
|
@@ -111,9 +235,20 @@ def per_record_shap(
|
|
|
111
235
|
feats = list(df.columns)
|
|
112
236
|
ids = df.index
|
|
113
237
|
booster = getattr(model, "booster_", model)
|
|
238
|
+
explain_input = df
|
|
239
|
+
if any(str(dt) == "category" for dt in df.dtypes):
|
|
240
|
+
try:
|
|
241
|
+
import xgboost as xgb
|
|
242
|
+
except ImportError:
|
|
243
|
+
pass # not an xgboost model; nothing to pre-convert
|
|
244
|
+
else:
|
|
245
|
+
# True only for a raw Booster: the sklearn wrapper carries the flag itself, and a
|
|
246
|
+
# LightGBM booster handles its own categoricals.
|
|
247
|
+
if isinstance(booster, xgb.Booster):
|
|
248
|
+
explain_input = xgb.DMatrix(df, enable_categorical=True)
|
|
114
249
|
with warnings.catch_warnings(): # silence shap's informational list-output notice
|
|
115
250
|
warnings.simplefilter("ignore")
|
|
116
|
-
sv = shap.TreeExplainer(booster, feature_perturbation="tree_path_dependent").shap_values(
|
|
251
|
+
sv = shap.TreeExplainer(booster, feature_perturbation="tree_path_dependent").shap_values(explain_input)
|
|
117
252
|
if isinstance(sv, list): # binary classifiers return [class0, class1]
|
|
118
253
|
sv = sv[-1]
|
|
119
254
|
sv = np.asarray(sv)
|
|
@@ -151,13 +286,13 @@ def per_record_counterfactuals(
|
|
|
151
286
|
model,
|
|
152
287
|
df,
|
|
153
288
|
shap_df,
|
|
154
|
-
model_algo="lgb",
|
|
155
289
|
unique_id="record_id",
|
|
156
290
|
peer_group=None,
|
|
157
291
|
candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9],
|
|
158
292
|
direction=None,
|
|
293
|
+
score_goal="maximize",
|
|
159
294
|
actionable_values=["Yes", "Partial"],
|
|
160
|
-
|
|
295
|
+
min_gain=0.005,
|
|
161
296
|
impact_medium=0.01,
|
|
162
297
|
impact_high=0.03,
|
|
163
298
|
levels={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")},
|
|
@@ -167,48 +302,78 @@ def per_record_counterfactuals(
|
|
|
167
302
|
"change X -> +Y", direction chosen empirically.
|
|
168
303
|
For each actionable lever SHAP surfaced for a record, evaluate a set of candidate
|
|
169
304
|
values (peer-group quantiles), re-score the model at each, and keep the single move
|
|
170
|
-
that maximizes the
|
|
171
|
-
|
|
172
|
-
|
|
305
|
+
that maximizes `gain` -- the improvement in the model score, signed by score_goal
|
|
306
|
+
(see below). The model decides whether that means increasing or decreasing the
|
|
307
|
+
feature, so non-monotonic and negatively related features are handled correctly.
|
|
308
|
+
No monotonicity assumption is made.
|
|
173
309
|
|
|
174
|
-
model, df,
|
|
310
|
+
model, df, unique_id : as in per_record_shap (df indexed by unique_id). The scoring call
|
|
311
|
+
is chosen from the model object -- .predict_proba() where available, otherwise a raw
|
|
312
|
+
xgboost.Booster or lightgbm.Booster is scored natively.
|
|
175
313
|
shap_df : per_record_shap output; scopes which (record, feature) levers to test.
|
|
314
|
+
Only numeric levers are evaluated: candidate values are peer-group quantiles, which
|
|
315
|
+
don't exist for a categorical, so non-numeric features are skipped with a warning.
|
|
316
|
+
Marking them 'none' in `direction` is the explicit way to express the same thing.
|
|
176
317
|
peer_group : Series/DataFrame indexed by unique_id; numeric columns auto-binned into
|
|
177
318
|
n_bins quantile bands. None -> one global group. Candidate values are drawn
|
|
178
319
|
from each feature's distribution within the record's peer group.
|
|
179
320
|
candidate_quantiles : peer-group quantiles evaluated as candidate values.
|
|
180
|
-
direction : optional per-feature
|
|
181
|
-
'
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
321
|
+
direction : optional per-feature constraint mapping feature -> 'increasing' |
|
|
322
|
+
'decreasing' | 'both' | 'none' (dict or Series). 'increasing'/'decreasing' restrict
|
|
323
|
+
candidates to moves in that direction (an achievability constraint); 'both' allows
|
|
324
|
+
either; 'none' excludes the feature from lever evaluation entirely, for features
|
|
325
|
+
with no meaningful good direction (categoricals, identifiers, context-only numerics).
|
|
326
|
+
Features absent from the mapping, or direction=None, default to 'both'. A blank/NaN
|
|
327
|
+
raises -- normalize it to 'none' where the feature dictionary is read.
|
|
328
|
+
score_goal : 'maximize' if a higher predicted probability of class 1 is the good
|
|
329
|
+
outcome (e.g. class 1 = uptier), 'minimize' if it is the bad one (e.g. class 1 =
|
|
330
|
+
churn). Sets the sign of `gain`, which drives best-move selection, the min_gain
|
|
331
|
+
filter, impact_bucket, and the output ordering. Only the outcome's polarity --
|
|
332
|
+
`direction` above is a separate, feature-level concept and is unaffected.
|
|
333
|
+
min_gain : drop levers whose best achievable gain is below this. Note this is a
|
|
334
|
+
threshold on gain, not on raw delta_p: under score_goal="minimize" a good move has
|
|
335
|
+
a negative delta_p but a positive gain, so this stays positive for both polarities.
|
|
336
|
+
Negative values (or -inf) are allowed, and keep moves that make the outcome worse --
|
|
337
|
+
useful for inspecting the full candidate distribution while tuning thresholds.
|
|
338
|
+
impact_medium, impact_high : gain thresholds bucketing each lever into
|
|
339
|
+
Low/Medium/High impact_bucket. gain shrinks mechanically near the tails of
|
|
185
340
|
the probability distribution, so a fixed pp threshold alone under-weights
|
|
186
341
|
moves for low-scoring/mid-level records relative to those near a level edge.
|
|
187
342
|
levels (below) corrects for that: any move that crosses a level boundary is
|
|
188
|
-
floored at "Medium" regardless of its raw
|
|
343
|
+
floored at "Medium" regardless of its raw gain, since crossing levels is
|
|
189
344
|
typically the thing that actually changes which downstream action gets taken.
|
|
190
|
-
Tune impact_medium/high against the real
|
|
345
|
+
Tune impact_medium/high against the real gain distribution for your model;
|
|
191
346
|
levels should mirror whatever thresholds are live in your own scoring/leveling
|
|
192
347
|
logic (if that logic considers factors beyond the score itself, levels are
|
|
193
348
|
only meant to detect crossings for impact_bucket purposes, not to reproduce
|
|
194
|
-
it exactly).
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
your own
|
|
349
|
+
it exactly). impact_bucket is only meaningful for gain > 0; under a negative
|
|
350
|
+
min_gain the retained harmful moves stay "Low" (the level-crossing floor does not
|
|
351
|
+
apply to them), so read gain's sign rather than the bucket alone.
|
|
352
|
+
levels : dict mapping label -> upper bound on P(class 1) for that level (e.g.
|
|
353
|
+
{"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")} means p <= 0.03 ->
|
|
354
|
+
"poor fit", 0.03 < p <= 0.10 -> "good fit", p > 0.10 -> "best fit"). The dict's key
|
|
355
|
+
order doesn't matter -- levels are sorted by their cutoff value, and labels follow
|
|
356
|
+
that order -- but the top level's cutoff must be float("inf") since it has no upper
|
|
357
|
+
bound. Cutoffs are always in P(class 1) space regardless of score_goal, and
|
|
358
|
+
crosses_level is a plain inequality, so name the labels for your own outcome: a
|
|
359
|
+
churn model would pass something like {"low risk": 0.03, "medium risk": 0.10,
|
|
360
|
+
"high risk": float("inf")}. Used only to detect level crossings for impact_bucket,
|
|
361
|
+
not to reproduce your production leveling logic.
|
|
204
362
|
n_bins : quantile bins for numeric peer_group columns.
|
|
205
363
|
|
|
206
364
|
Returns a long DataFrame: one row per (record, lever) with the best move found
|
|
207
365
|
(current_value, intervention_value, intervention_quantile, p_before/p_after/delta_p,
|
|
208
|
-
impact_bucket, level_before, level_after, crosses_level).
|
|
366
|
+
gain, impact_bucket, level_before, level_after, crosses_level). delta_p is the raw
|
|
367
|
+
signed change in P(class 1); gain is that change signed so bigger is always better.
|
|
209
368
|
"""
|
|
210
369
|
if impact_medium >= impact_high:
|
|
211
370
|
raise ValueError(f"impact_medium ({impact_medium}) must be less than impact_high ({impact_high})")
|
|
371
|
+
_score_goal = str(score_goal).strip().lower()
|
|
372
|
+
if _score_goal not in ("maximize", "minimize"):
|
|
373
|
+
raise ValueError(f"score_goal must be 'maximize' or 'minimize', got {score_goal!r}")
|
|
374
|
+
# Sign that turns delta_p (always in P(class 1) space) into a gain where bigger is
|
|
375
|
+
# better, so every good/bad comparison below reads the same way for both polarities.
|
|
376
|
+
sign = 1.0 if _score_goal == "maximize" else -1.0
|
|
212
377
|
ids = df.index
|
|
213
378
|
# Peer-group key: numeric peer columns auto-binned into n_bins quantile bands.
|
|
214
379
|
if peer_group is None:
|
|
@@ -222,29 +387,32 @@ def per_record_counterfactuals(
|
|
|
222
387
|
if pd.api.types.is_numeric_dtype(pk[c]) else pk[c].astype(str))
|
|
223
388
|
key = band if key is None else key.str.cat(band, sep=" | ")
|
|
224
389
|
grp = pd.Series(np.asarray(key), index=ids)
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
"both": "both"}
|
|
230
|
-
direction = {k: _dirmap.get(str(v).lower(), "both")
|
|
390
|
+
# increasing/decreasing/both/none from the dictionary's good_direction column, mapped to
|
|
391
|
+
# the internal up/down/both/none. Unrecognized values raise rather than defaulting.
|
|
392
|
+
_internal = {"increasing": "up", "decreasing": "down", "both": "both", "none": "none"}
|
|
393
|
+
direction = {k: _internal[_norm_direction(v, f"direction[{k!r}]: ")]
|
|
231
394
|
for k, v in (dict(direction).items() if direction is not None else [])}
|
|
232
|
-
|
|
233
|
-
if model_algo.lower() in ("xgb", "xgboost") and not hasattr(model, "predict_proba"):
|
|
234
|
-
import xgboost as xgb
|
|
235
|
-
return np.asarray(model.predict(xgb.DMatrix(X)))
|
|
236
|
-
return model.predict_proba(X)[:, 1]
|
|
395
|
+
proba = _binary_proba_fn(model)
|
|
237
396
|
p0 = pd.Series(proba(df), index=ids) # unclipped baseline
|
|
238
397
|
levers = shap_df[shap_df["actionable"].isin(actionable_values)]
|
|
239
398
|
records_by_feat = levers.groupby("feature")[unique_id].apply(set)
|
|
399
|
+
# Candidate values are peer-group quantiles, so only quantile-able levers can be tested.
|
|
400
|
+
# Checked up front rather than inside the loop so the warning lists them all at once.
|
|
401
|
+
# 'none' levers are filtered first, so marking them explicitly suppresses the warning.
|
|
402
|
+
eligible = set(_peer_quantile_eligible(
|
|
403
|
+
df,
|
|
404
|
+
[f for f in records_by_feat.index if f in df.columns and direction.get(f, "both") != "none"],
|
|
405
|
+
"per_record_counterfactuals",
|
|
406
|
+
"Set their good_direction to 'none' (or actionable to 'No') to make this explicit.",
|
|
407
|
+
))
|
|
240
408
|
Xw = df.copy() # single reusable working matrix
|
|
241
409
|
parts = []
|
|
242
410
|
for f, recs in records_by_feat.items():
|
|
243
|
-
if f not in
|
|
411
|
+
if f not in eligible:
|
|
244
412
|
continue
|
|
413
|
+
allowed = direction.get(f, "both")
|
|
245
414
|
cur = df[f]
|
|
246
415
|
in_scope = pd.Series(ids.isin(recs), index=ids)
|
|
247
|
-
allowed = direction.get(f, "both")
|
|
248
416
|
orig = df[f].values
|
|
249
417
|
for q in candidate_quantiles:
|
|
250
418
|
cand = df.groupby(grp)[f].transform(lambda s: s.quantile(q))
|
|
@@ -273,17 +441,18 @@ def per_record_counterfactuals(
|
|
|
273
441
|
if not parts:
|
|
274
442
|
return pd.DataFrame()
|
|
275
443
|
out = pd.concat(parts, ignore_index=True)
|
|
444
|
+
out["gain"] = sign * out["delta_p"]
|
|
276
445
|
# Keep the single best-scoring candidate per (record, feature).
|
|
277
|
-
out = (out.sort_values("
|
|
446
|
+
out = (out.sort_values("gain", ascending=False)
|
|
278
447
|
.drop_duplicates([unique_id, "feature"], keep="first"))
|
|
279
|
-
out = out[out["
|
|
280
|
-
|
|
281
|
-
raise ValueError(f"impact_medium ({impact_medium}) must be less than impact_high ({impact_high})")
|
|
282
|
-
out["impact_bucket"] = pd.cut(out["delta_p"],
|
|
448
|
+
out = out[out["gain"] >= min_gain]
|
|
449
|
+
out["impact_bucket"] = pd.cut(out["gain"],
|
|
283
450
|
bins=[-np.inf, impact_medium, impact_high, np.inf],
|
|
284
451
|
labels=["Low", "Medium", "High"]).astype(str)
|
|
285
452
|
# Level-crossing floor: a move that pushes the record into a better level is at
|
|
286
|
-
# least "Medium" impact even if its raw
|
|
453
|
+
# least "Medium" impact even if its raw gain is small (see docstring). Level cutoffs
|
|
454
|
+
# stay in P(class 1) space for both polarities -- crosses_level is an inequality, so
|
|
455
|
+
# only the caller's choice of labels is outcome-specific.
|
|
287
456
|
_sorted_levels = sorted(levels.items(), key=lambda kv: kv[1])
|
|
288
457
|
_edges = [-np.inf] + [cutoff for _, cutoff in _sorted_levels]
|
|
289
458
|
_labels = [label for label, _ in _sorted_levels]
|
|
@@ -292,8 +461,12 @@ def per_record_counterfactuals(
|
|
|
292
461
|
out["level_before"] = _level(out["p_before"])
|
|
293
462
|
out["level_after"] = _level(out["p_after"])
|
|
294
463
|
out["crosses_level"] = out["level_before"] != out["level_after"]
|
|
295
|
-
|
|
296
|
-
|
|
464
|
+
# Gated on gain > 0: with a negative min_gain the output retains moves that make the
|
|
465
|
+
# outcome worse, and those cross level boundaries in the wrong direction. Promoting them
|
|
466
|
+
# would advertise the most harmful move found as a Medium-impact lever.
|
|
467
|
+
out.loc[out["crosses_level"] & (out["gain"] > 0) & (out["impact_bucket"] == "Low"),
|
|
468
|
+
"impact_bucket"] = "Medium"
|
|
469
|
+
return out.sort_values([unique_id, "gain"], ascending=[True, False]).reset_index(drop=True)
|
|
297
470
|
|
|
298
471
|
|
|
299
472
|
def per_record_peer_percentiles(
|
|
@@ -318,6 +491,10 @@ def per_record_peer_percentiles(
|
|
|
318
491
|
you want, including non-actionable descriptive fields.
|
|
319
492
|
If None (default), computed for every actionable model feature in df.
|
|
320
493
|
|
|
494
|
+
Either way, only numeric non-bool columns are returned: percentile ranks and quantiles
|
|
495
|
+
need an ordered numeric scale. Categoricals, bools, and strings are skipped with a
|
|
496
|
+
warning naming them (see _peer_quantile_eligible for why each is excluded).
|
|
497
|
+
|
|
321
498
|
Returns a long DataFrame: one row per (record, feature).
|
|
322
499
|
"""
|
|
323
500
|
if isinstance(feature_dict, str):
|
|
@@ -327,6 +504,10 @@ def per_record_peer_percentiles(
|
|
|
327
504
|
else:
|
|
328
505
|
act_fields = set(feature_dict.loc[feature_dict["actionable"].isin(actionable_values), "field"])
|
|
329
506
|
cols = [f for f in df.columns if f in act_fields]
|
|
507
|
+
cols = _peer_quantile_eligible(
|
|
508
|
+
df, cols, "per_record_peer_percentiles",
|
|
509
|
+
"Drop them from `features`, or mark them actionable='No' in the feature dictionary.",
|
|
510
|
+
)
|
|
330
511
|
ids = df.index
|
|
331
512
|
# Peer-group key: numeric peer columns auto-binned into n_bins quantile bands.
|
|
332
513
|
if peer_group is None:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlabds
|
|
3
|
-
Version: 2.2.
|
|
3
|
+
Version: 2.2.3
|
|
4
4
|
Summary: GitLab Data Science Tools
|
|
5
5
|
Author-email: Kevin Dietz <kdietz@gitlab.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -827,10 +827,10 @@ Status label for a value vs. its peer median (+/- tolerance), not percentile ran
|
|
|
827
827
|
#### Parameters:
|
|
828
828
|
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
829
829
|
- _**peer_median**_ : peer group median(s) to compare against
|
|
830
|
-
- _**good_direction**_ : `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better).
|
|
830
|
+
- _**good_direction**_ : one of `"increasing"`, `"decreasing"`, `"both"`, `"none"` (scalar or array-like). `"decreasing"` flips which side of the median counts as "above" (e.g., for a metric where lower is better). `"both"` and `"none"` mean the feature has no single good direction, so the output is positional only: no flip, and the `ceiling_label` upgrade is suppressed. `None` (the default) is treated as `"none"`. Any other value raises — a blank/NaN in a feature dictionary must be normalized to `"none"` first rather than silently defaulting to increasing.
|
|
831
831
|
- _**tolerance**_ : relative distance from the peer median (as a fraction of the median) required to be classified above/below average rather than "average". Default is 0.15 (a value must be >15% above/below its peer median to leave the "average" band)
|
|
832
832
|
- _**no_activity_value**_ : value that should always map to `labels[3]` ("No activity") regardless of tolerance
|
|
833
|
-
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`
|
|
833
|
+
- _**peer_percentile**_ : optional 0-1 percentile rank within the peer group. When provided, an "above average" reading that also sits in the top `ceiling_pct` toward `good_direction` is upgraded to `ceiling_label`. Only applies when `good_direction` is `"increasing"` or `"decreasing"`
|
|
834
834
|
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
835
835
|
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
836
836
|
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
@@ -859,7 +859,7 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
859
859
|
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
860
860
|
|
|
861
861
|
#### Parameters:
|
|
862
|
-
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
862
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost). A raw `xgboost.Booster` (from `xgb.train`, or loaded back from a saved model file) works as well as the sklearn wrapper
|
|
863
863
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
864
864
|
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
865
865
|
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
@@ -870,6 +870,8 @@ Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tre
|
|
|
870
870
|
#### Returns
|
|
871
871
|
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
872
872
|
|
|
873
|
+
`direction` ("increases"/"decreases") describes the effect on the model's predicted probability of class 1, not on the business outcome. Where class 1 is a bad outcome (e.g. churn), "increases" means worse — any narrative or scorecard layer reading this column has to apply that polarity itself.
|
|
874
|
+
|
|
873
875
|
#### Requirements
|
|
874
876
|
This function requires the `shap` package (included in core dependencies).
|
|
875
877
|
|
|
@@ -889,27 +891,29 @@ shap_df = gitlabds.per_record_shap(
|
|
|
889
891
|
<details><summary> Per-Record Counterfactuals </summary>
|
|
890
892
|
|
|
891
893
|
#### Description
|
|
892
|
-
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes the
|
|
894
|
+
"Change X -> +Y" counterfactual search, direction chosen empirically. For each actionable lever SHAP surfaced for a record, evaluates candidate values drawn from peer-group quantiles, re-scores the model at each, and keeps the single move that maximizes `gain` — the improvement in the model score, signed by `score_goal` so that bigger is always better. No monotonicity assumption is made.
|
|
893
895
|
|
|
894
|
-
`gitlabds.per_record_counterfactuals(model, df, shap_df,
|
|
896
|
+
`gitlabds.per_record_counterfactuals(model, df, shap_df, unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, score_goal="maximize", actionable_values=["Yes", "Partial"], min_gain=0.005, impact_medium=0.01, impact_high=0.03, levels={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
895
897
|
|
|
896
898
|
#### Parameters:
|
|
897
|
-
- _**model**_ : trained model (as in `per_record_shap`)
|
|
899
|
+
- _**model**_ : trained model (as in `per_record_shap`). How probabilities are scored is chosen from the model object itself — `.predict_proba()` where available, otherwise a raw `xgboost.Booster` or `lightgbm.Booster` is scored natively. An unsupported type raises a `TypeError` naming it
|
|
898
900
|
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
899
|
-
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test
|
|
900
|
-
- _**model_algo**_ : `"lgb"` or `"xgb"`/`"xgboost"`; controls how probabilities are scored
|
|
901
|
+
- _**shap_df**_ : output of `per_record_shap`; scopes which (record, feature) levers to test. Only numeric non-bool levers are evaluated — candidate values are peer-group quantiles, which need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning`. Marking them `'none'` in `direction` is the explicit way to express the same thing, and suppresses the warning
|
|
901
902
|
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
902
903
|
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
903
904
|
- _**candidate_quantiles**_ : peer-group quantiles evaluated as candidate values
|
|
904
|
-
- _**direction**_ : optional per-feature
|
|
905
|
+
- _**direction**_ : optional per-feature constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` | `'none'` (dict or Series). `'increasing'`/`'decreasing'` restrict candidates to moves in that direction (an achievability constraint); `'both'` allows either; `'none'` excludes the feature from lever evaluation entirely, for features with no meaningful good direction (categoricals, identifiers, context-only numerics). Features absent from the mapping, or `direction=None`, default to `'both'`. A blank/NaN raises — normalize it to `'none'` where the feature dictionary is read
|
|
906
|
+
- _**score_goal**_ : `"maximize"` if a higher predicted probability of class 1 is the good outcome (e.g. class 1 = uptier), `"minimize"` if it is the bad one (e.g. class 1 = churn). Sets the sign of `gain`, which drives best-move selection, the `min_gain` filter, `impact_bucket`, and the output ordering. This is the outcome's polarity only — `direction` above is a separate, feature-level concept and is unaffected. Default is `"maximize"`
|
|
905
907
|
- _**actionable_values**_ : values in `shap_df["actionable"]` treated as actionable levers
|
|
906
|
-
- _**
|
|
907
|
-
- _**impact_medium, impact_high**_ :
|
|
908
|
-
- _**levels**_ : dict mapping label -> upper bound on
|
|
908
|
+
- _**min_gain**_ : drop levers whose best achievable gain is below this. Note this is a threshold on `gain`, not on raw `delta_p`: under `score_goal="minimize"` a good move has a negative `delta_p` but a positive `gain`, so this stays positive for both polarities. Negative values (or `-inf`) are allowed, and keep moves that make the outcome worse — useful for inspecting the full candidate distribution while tuning thresholds
|
|
909
|
+
- _**impact_medium, impact_high**_ : `gain` thresholds bucketing each lever into Low/Medium/High `impact_bucket`. Only meaningful for `gain > 0` — under a negative `min_gain` the retained harmful moves stay `"Low"` (the level-crossing floor doesn't apply to them), so read `gain`'s sign rather than the bucket alone
|
|
910
|
+
- _**levels**_ : dict mapping label -> upper bound on P(class 1) for that level (e.g., default `{"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}` means `p <= 0.03` -> "poor fit", `0.03 < p <= 0.10` -> "good fit", `p > 0.10` -> "best fit"). Key order doesn't matter — levels are sorted by cutoff and labels follow that order — but the top level's cutoff must be `float("inf")`. Cutoffs are always in P(class 1) space regardless of `score_goal`, and `crosses_level` is a plain inequality, so name the labels for your own outcome: a churn model would pass something like `{"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")}`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact)
|
|
909
911
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
910
912
|
|
|
911
913
|
#### Returns
|
|
912
|
-
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
914
|
+
- Long DataFrame, one row per (record, lever): `current_value`, `intervention_value`, `intervention_quantile`, `p_before`, `p_after`, `delta_p`, `gain`, `impact_bucket`, `level_before`, `level_after`, `crosses_level`
|
|
915
|
+
|
|
916
|
+
`delta_p` is the raw signed change in P(class 1); `gain` is that change signed by `score_goal` so bigger is always better. For a `score_goal="minimize"` model, `delta_p` will be negative and `gain` positive.
|
|
913
917
|
|
|
914
918
|
#### Requirements
|
|
915
919
|
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
@@ -924,6 +928,17 @@ counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
|
924
928
|
shap_df=shap_df,
|
|
925
929
|
peer_group=peer_group_df,
|
|
926
930
|
)
|
|
931
|
+
|
|
932
|
+
# Churn model: class 1 = churn, so a lower score is the good outcome.
|
|
933
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
934
|
+
model=churn_model,
|
|
935
|
+
df=model_features,
|
|
936
|
+
shap_df=shap_df,
|
|
937
|
+
peer_group=peer_group_df,
|
|
938
|
+
score_goal="minimize",
|
|
939
|
+
direction=dict(zip(feature_dict["field"], feature_dict["good_direction"])),
|
|
940
|
+
levels={"low risk": 0.03, "medium risk": 0.10, "high risk": float("inf")},
|
|
941
|
+
)
|
|
927
942
|
```
|
|
928
943
|
|
|
929
944
|
</details>
|
|
@@ -944,6 +959,8 @@ Peer benchmarking. Each record's percentile for each feature within its peer gro
|
|
|
944
959
|
- _**features**_ : optional explicit list of features to benchmark (used as-is, no actionable filter). If `None` (default), computed for every actionable feature in `df`
|
|
945
960
|
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
946
961
|
|
|
962
|
+
Either way, only numeric non-bool columns are benchmarked — percentile ranks and quantiles need an ordered numeric scale. Categoricals, bools, and strings are skipped with a `UserWarning` naming them.
|
|
963
|
+
|
|
947
964
|
#### Returns
|
|
948
965
|
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
949
966
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|