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.
Files changed (27) hide show
  1. {gitlabds-2.2.2 → gitlabds-2.2.3}/PKG-INFO +31 -14
  2. {gitlabds-2.2.2 → gitlabds-2.2.3}/README.md +30 -13
  3. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/record_benchmarking.py +234 -53
  4. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/PKG-INFO +31 -14
  5. {gitlabds-2.2.2 → gitlabds-2.2.3}/LICENSE +0 -0
  6. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/__init__.py +0 -0
  7. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/baselines.py +0 -0
  8. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/config_generator.py +0 -0
  9. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/dummy.py +0 -0
  10. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/feature_descriptions.py +0 -0
  11. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/feature_reduction.py +0 -0
  12. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/insights.py +0 -0
  13. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/memory_optimization.py +0 -0
  14. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/missing.py +0 -0
  15. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/model_evaluator.py +0 -0
  16. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/monitoring_metrics.py +0 -0
  17. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/outliers.py +0 -0
  18. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/serving_features.py +0 -0
  19. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/split_data.py +0 -0
  20. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds/trends.py +0 -0
  21. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/SOURCES.txt +0 -0
  22. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/dependency_links.txt +0 -0
  23. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/requires.txt +0 -0
  24. {gitlabds-2.2.2 → gitlabds-2.2.3}/gitlabds.egg-info/top_level.txt +0 -0
  25. {gitlabds-2.2.2 → gitlabds-2.2.3}/pyproject.toml +0 -0
  26. {gitlabds-2.2.2 → gitlabds-2.2.3}/setup.cfg +0 -0
  27. {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.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). Anything else is treated as increasing/default.
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 gain in predicted probability. No monotonicity assumption is made.
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, model_algo="lgb", unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, actionable_values=["Yes", "Partial"], min_delta_p=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):`
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 achievability constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` (dict or Series)
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
- - _**min_delta_p**_ : drop levers whose best achievable gain is below this
907
- - _**impact_medium, impact_high**_ : delta_p thresholds bucketing each lever into Low/Medium/High `impact_bucket`
908
- - _**levels**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that level, ordered worst to best (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 (sorted by cutoff), but the top level's cutoff must be `float("inf")`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact). Pass your own dict (e.g., `{"low": 0.03, "medium": 0.10, "high": float("inf")}`) to match your own pipeline
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). Anything else is treated as increasing/default.
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 gain in predicted probability. No monotonicity assumption is made.
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, model_algo="lgb", unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, actionable_values=["Yes", "Partial"], min_delta_p=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):`
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 achievability constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` (dict or Series)
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
- - _**min_delta_p**_ : drop levers whose best achievable gain is below this
875
- - _**impact_medium, impact_high**_ : delta_p thresholds bucketing each lever into Low/Medium/High `impact_bucket`
876
- - _**levels**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that level, ordered worst to best (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 (sorted by cutoff), but the top level's cutoff must be `float("inf")`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact). Pass your own dict (e.g., `{"low": 0.03, "medium": 0.10, "high": float("inf")}`) to match your own pipeline
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 : "decreasing" flips which side of the median counts as "above"
28
- (e.g., for a metric where lower is better). Anything else (including None,
29
- the default) is treated as increasing.
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
- decreasing = pd.Series(good_direction, index=value.index).astype(str).str.lower().eq("decreasing") \
52
- if good_direction is not None else pd.Series(False, index=value.index)
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(df)
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
- min_delta_p=0.005,
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 gain in predicted probability. The model decides whether that
171
- means increasing or decreasing the feature, so non-monotonic and negatively related
172
- features are handled correctly. No monotonicity assumption is made.
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, model_algo, unique_id : as in per_record_shap (df indexed by unique_id).
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 achievability constraint mapping feature ->
181
- 'increasing' | 'decreasing' | 'both' (dict or Series). Missing/None -> 'both' (unconstrained).
182
- min_delta_p : drop levers whose best achievable gain is below this.
183
- impact_medium, impact_high : delta_p thresholds bucketing each lever into
184
- Low/Medium/High impact_bucket. delta_p shrinks mechanically near the tails of
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 delta_p, since crossing levels is
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 delta_p distribution for your model;
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
- levels : dict mapping label -> upper bound (on p_before/p_after) for that level,
196
- ordered worst to best (e.g. {"poor fit": 0.03, "good fit": 0.10, "best fit":
197
- float("inf")} means p <= 0.03 -> "poor fit", 0.03 < p <= 0.10 -> "good fit",
198
- p > 0.10 -> "best fit"). The dict's key order doesn't matter -- levels are
199
- sorted by their cutoff value -- but the highest-scoring level's cutoff must be
200
- float("inf") since it has no upper bound. Used only to detect level crossings
201
- for impact_bucket, not to reproduce your production leveling logic. Pass your
202
- own dict (e.g. {"low": 0.03, "medium": 0.10, "high": float("inf")}) to match
203
- your own pipeline.
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
- # Accept up/down/both or increasing/decreasing/both (from the dictionary
226
- # good_direction column); anything else -> "both".
227
- _dirmap = {"increasing": "up", "increase": "up", "up": "up",
228
- "decreasing": "down", "decrease": "down", "down": "down",
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
- def proba(X):
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 df.columns:
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("delta_p", ascending=False)
446
+ out = (out.sort_values("gain", ascending=False)
278
447
  .drop_duplicates([unique_id, "feature"], keep="first"))
279
- out = out[out["delta_p"] >= min_delta_p]
280
- if impact_medium >= impact_high:
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 delta_p is small (see docstring).
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
- out.loc[out["crosses_level"] & (out["impact_bucket"] == "Low"), "impact_bucket"] = "Medium"
296
- return out.sort_values([unique_id, "delta_p"], ascending=[True, False]).reset_index(drop=True)
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.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). Anything else is treated as increasing/default.
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 gain in predicted probability. No monotonicity assumption is made.
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, model_algo="lgb", unique_id="record_id", peer_group=None, candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9], direction=None, actionable_values=["Yes", "Partial"], min_delta_p=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):`
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 achievability constraint mapping feature -> `'increasing'` | `'decreasing'` | `'both'` (dict or Series)
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
- - _**min_delta_p**_ : drop levers whose best achievable gain is below this
907
- - _**impact_medium, impact_high**_ : delta_p thresholds bucketing each lever into Low/Medium/High `impact_bucket`
908
- - _**levels**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that level, ordered worst to best (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 (sorted by cutoff), but the top level's cutoff must be `float("inf")`. Used to detect whether a move crosses a level boundary (any crossing move is floored at "Medium" impact). Pass your own dict (e.g., `{"low": 0.03, "medium": 0.10, "high": float("inf")}`) to match your own pipeline
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