gitlabds 2.1.11__tar.gz → 2.2.1__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.1.11 → gitlabds-2.2.1}/PKG-INFO +186 -2
- {gitlabds-2.1.11 → gitlabds-2.2.1}/README.md +185 -1
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/__init__.py +4 -1
- gitlabds-2.2.1/gitlabds/feature_descriptions.py +106 -0
- gitlabds-2.2.1/gitlabds/record_benchmarking.py +360 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds.egg-info/PKG-INFO +186 -2
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds.egg-info/SOURCES.txt +2 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/LICENSE +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/baselines.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/config_generator.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/dummy.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/feature_reduction.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/insights.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/memory_optimization.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/missing.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/model_evaluator.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/monitoring_metrics.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/outliers.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/serving_features.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/split_data.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds/trends.py +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds.egg-info/dependency_links.txt +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds.egg-info/requires.txt +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/gitlabds.egg-info/top_level.txt +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/pyproject.toml +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/setup.cfg +0 -0
- {gitlabds-2.1.11 → gitlabds-2.2.1}/setup.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlabds
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: GitLab Data Science Tools
|
|
5
5
|
Author-email: Kevin Dietz <kdietz@gitlab.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -63,7 +63,7 @@ pip install gitlabds
|
|
|
63
63
|
|
|
64
64
|
#### Snowflake Feature Store
|
|
65
65
|
|
|
66
|
-
The `serve_features` and `
|
|
66
|
+
The `serve_features`, `clear_feature_serving_locks`, and `get_feature_descriptions` functions require additional Snowflake dependencies.
|
|
67
67
|
|
|
68
68
|
To use these functions, install with:
|
|
69
69
|
|
|
@@ -815,6 +815,151 @@ results = gitlabds.prescriptions(
|
|
|
815
815
|
```
|
|
816
816
|
</details>
|
|
817
817
|
|
|
818
|
+
### Record Benchmarking
|
|
819
|
+
|
|
820
|
+
<details><summary> Value vs. Peer Status </summary>
|
|
821
|
+
|
|
822
|
+
#### Description
|
|
823
|
+
Status label for a value vs. its peer median (+/- tolerance), not percentile rank.
|
|
824
|
+
|
|
825
|
+
`gitlabds.value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15, no_activity_value=None, peer_percentile=None, ceiling_pct=0.95, ceiling_label="near ceiling", labels=("below average", "average", "above average", "No activity")):`
|
|
826
|
+
|
|
827
|
+
#### Parameters:
|
|
828
|
+
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
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.
|
|
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
|
+
- _**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`
|
|
834
|
+
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
835
|
+
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
836
|
+
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
837
|
+
|
|
838
|
+
#### Returns
|
|
839
|
+
- Series of level labels, one per input value
|
|
840
|
+
|
|
841
|
+
#### Examples:
|
|
842
|
+
```python
|
|
843
|
+
import gitlabds
|
|
844
|
+
|
|
845
|
+
level = gitlabds.value_vs_peer_status(
|
|
846
|
+
value=df["monthly_spend"],
|
|
847
|
+
peer_median=df["peer_median_spend"],
|
|
848
|
+
peer_percentile=df["peer_percentile_spend"],
|
|
849
|
+
)
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
</details>
|
|
853
|
+
|
|
854
|
+
<details><summary> Per-Record SHAP </summary>
|
|
855
|
+
|
|
856
|
+
#### Description
|
|
857
|
+
Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tree_path_dependent TreeSHAP, which natively handles the model's categorical splits. Returns the top_n features by |contribution| per record, guaranteeing at least min_actionable actionable ones.
|
|
858
|
+
|
|
859
|
+
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
860
|
+
|
|
861
|
+
#### Parameters:
|
|
862
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
863
|
+
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
864
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
865
|
+
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
866
|
+
- _**top_n**_ : number of features to return per record, ranked by |contribution|. Default is 25
|
|
867
|
+
- _**min_actionable**_ : minimum number of actionable features guaranteed per record, even if outside the top_n. Default is 3
|
|
868
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
869
|
+
|
|
870
|
+
#### Returns
|
|
871
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
872
|
+
|
|
873
|
+
#### Requirements
|
|
874
|
+
This function requires the `shap` package (included in core dependencies).
|
|
875
|
+
|
|
876
|
+
#### Examples:
|
|
877
|
+
```python
|
|
878
|
+
import gitlabds
|
|
879
|
+
|
|
880
|
+
shap_df = gitlabds.per_record_shap(
|
|
881
|
+
model=trained_model,
|
|
882
|
+
df=model_features,
|
|
883
|
+
feature_dict="feature_dict.csv",
|
|
884
|
+
)
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
</details>
|
|
888
|
+
|
|
889
|
+
<details><summary> Per-Record Counterfactuals </summary>
|
|
890
|
+
|
|
891
|
+
#### 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.
|
|
893
|
+
|
|
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, bands={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
895
|
+
|
|
896
|
+
#### Parameters:
|
|
897
|
+
- _**model**_ : trained model (as in `per_record_shap`)
|
|
898
|
+
- _**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
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
902
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
903
|
+
- _**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
|
+
- _**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
|
+
- _**bands**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that band, 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 band's cutoff must be `float("inf")`. Used to detect whether a move crosses a band 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
|
|
909
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
910
|
+
|
|
911
|
+
#### 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_band`
|
|
913
|
+
|
|
914
|
+
#### Requirements
|
|
915
|
+
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
916
|
+
|
|
917
|
+
#### Examples:
|
|
918
|
+
```python
|
|
919
|
+
import gitlabds
|
|
920
|
+
|
|
921
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
922
|
+
model=trained_model,
|
|
923
|
+
df=model_features,
|
|
924
|
+
shap_df=shap_df,
|
|
925
|
+
peer_group=peer_group_df,
|
|
926
|
+
)
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
</details>
|
|
930
|
+
|
|
931
|
+
<details><summary> Per-Record Peer Percentiles </summary>
|
|
932
|
+
|
|
933
|
+
#### Description
|
|
934
|
+
Peer benchmarking. Each record's percentile for each feature within its peer group, plus the group's p25/p50/p75 for context. Pure pandas -- no model required.
|
|
935
|
+
|
|
936
|
+
`gitlabds.per_record_peer_percentiles(df, feature_dict, peer_group=None, unique_id="record_id", actionable_values=["Yes", "Partial"], features=None, n_bins=5):`
|
|
937
|
+
|
|
938
|
+
#### Parameters:
|
|
939
|
+
- _**df**_ : model features DataFrame, indexed by `unique_id`
|
|
940
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
941
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
942
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
943
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
944
|
+
- _**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
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
946
|
+
|
|
947
|
+
#### Returns
|
|
948
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
949
|
+
|
|
950
|
+
#### Examples:
|
|
951
|
+
```python
|
|
952
|
+
import gitlabds
|
|
953
|
+
|
|
954
|
+
percentiles_df = gitlabds.per_record_peer_percentiles(
|
|
955
|
+
df=model_features,
|
|
956
|
+
feature_dict="feature_dict.csv",
|
|
957
|
+
peer_group=peer_group_df,
|
|
958
|
+
)
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
</details>
|
|
962
|
+
|
|
818
963
|
### Model Monitoring
|
|
819
964
|
|
|
820
965
|
<details><summary> Generate Baseline Features </summary>
|
|
@@ -1072,6 +1217,45 @@ print(f"Cleared {locks_cleared} locks")
|
|
|
1072
1217
|
|
|
1073
1218
|
</details>
|
|
1074
1219
|
|
|
1220
|
+
<details><summary> Get Feature Descriptions </summary>
|
|
1221
|
+
|
|
1222
|
+
#### Description
|
|
1223
|
+
Look up each feature's source feature view and description from the feature store's registered feature views (column comments set via `FeatureView.attach_feature_desc(...)`). Useful for documenting a model's fields or auditing everything registered in the store.
|
|
1224
|
+
|
|
1225
|
+
`gitlabds.get_feature_descriptions(session, feature_store, features=None):`
|
|
1226
|
+
|
|
1227
|
+
#### Parameters:
|
|
1228
|
+
- **_session_** : Active Snowpark session (e.g., `fs_engine._session`)
|
|
1229
|
+
- **_feature_store_** : Initialized FeatureStore instance (e.g., `fs_engine`); used to enumerate the registered feature views and locate their db/schema
|
|
1230
|
+
- **_features_** : Feature names to look up. If None, returns every feature in the store's registered feature views.
|
|
1231
|
+
|
|
1232
|
+
#### Returns
|
|
1233
|
+
- DataFrame with columns: `feature`, `feature_view`, `data_type`, `description`. Features found in more than one feature view have their views/descriptions joined (`" | "` / `" || "`) so nothing is dropped. When `features` is provided, unmatched features (e.g., transformed or dummy-coded features) come back with blank `feature_view` / `data_type` / `description` for the user to populate manually, and the caller's order/list is preserved.
|
|
1234
|
+
|
|
1235
|
+
#### Requirements
|
|
1236
|
+
This function requires Snowflake packages. Install with:
|
|
1237
|
+
```bash
|
|
1238
|
+
pip install snowflake-snowpark-python snowflake-ml-python
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
#### Examples:
|
|
1242
|
+
|
|
1243
|
+
```python
|
|
1244
|
+
import gitlabds
|
|
1245
|
+
|
|
1246
|
+
# Look up a specific list (e.g. the model fields from a config)
|
|
1247
|
+
desc = gitlabds.get_feature_descriptions(
|
|
1248
|
+
session=fs_engine._session,
|
|
1249
|
+
feature_store=fs_engine,
|
|
1250
|
+
features=scoring_config["model"]["fields"],
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Or pull every feature registered in the store
|
|
1254
|
+
all_desc = gitlabds.get_feature_descriptions(session=fs_engine._session, feature_store=fs_engine)
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
</details>
|
|
1258
|
+
|
|
1075
1259
|
### SQL and Trend Analysis
|
|
1076
1260
|
|
|
1077
1261
|
<details><summary> SQL Trend Query Generator </summary>
|
|
@@ -31,7 +31,7 @@ pip install gitlabds
|
|
|
31
31
|
|
|
32
32
|
#### Snowflake Feature Store
|
|
33
33
|
|
|
34
|
-
The `serve_features` and `
|
|
34
|
+
The `serve_features`, `clear_feature_serving_locks`, and `get_feature_descriptions` functions require additional Snowflake dependencies.
|
|
35
35
|
|
|
36
36
|
To use these functions, install with:
|
|
37
37
|
|
|
@@ -783,6 +783,151 @@ results = gitlabds.prescriptions(
|
|
|
783
783
|
```
|
|
784
784
|
</details>
|
|
785
785
|
|
|
786
|
+
### Record Benchmarking
|
|
787
|
+
|
|
788
|
+
<details><summary> Value vs. Peer Status </summary>
|
|
789
|
+
|
|
790
|
+
#### Description
|
|
791
|
+
Status label for a value vs. its peer median (+/- tolerance), not percentile rank.
|
|
792
|
+
|
|
793
|
+
`gitlabds.value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15, no_activity_value=None, peer_percentile=None, ceiling_pct=0.95, ceiling_label="near ceiling", labels=("below average", "average", "above average", "No activity")):`
|
|
794
|
+
|
|
795
|
+
#### Parameters:
|
|
796
|
+
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
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.
|
|
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
|
+
- _**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`
|
|
802
|
+
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
803
|
+
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
804
|
+
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
805
|
+
|
|
806
|
+
#### Returns
|
|
807
|
+
- Series of level labels, one per input value
|
|
808
|
+
|
|
809
|
+
#### Examples:
|
|
810
|
+
```python
|
|
811
|
+
import gitlabds
|
|
812
|
+
|
|
813
|
+
level = gitlabds.value_vs_peer_status(
|
|
814
|
+
value=df["monthly_spend"],
|
|
815
|
+
peer_median=df["peer_median_spend"],
|
|
816
|
+
peer_percentile=df["peer_percentile_spend"],
|
|
817
|
+
)
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
</details>
|
|
821
|
+
|
|
822
|
+
<details><summary> Per-Record SHAP </summary>
|
|
823
|
+
|
|
824
|
+
#### Description
|
|
825
|
+
Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tree_path_dependent TreeSHAP, which natively handles the model's categorical splits. Returns the top_n features by |contribution| per record, guaranteeing at least min_actionable actionable ones.
|
|
826
|
+
|
|
827
|
+
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
828
|
+
|
|
829
|
+
#### Parameters:
|
|
830
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
831
|
+
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
832
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
833
|
+
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
834
|
+
- _**top_n**_ : number of features to return per record, ranked by |contribution|. Default is 25
|
|
835
|
+
- _**min_actionable**_ : minimum number of actionable features guaranteed per record, even if outside the top_n. Default is 3
|
|
836
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
837
|
+
|
|
838
|
+
#### Returns
|
|
839
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
840
|
+
|
|
841
|
+
#### Requirements
|
|
842
|
+
This function requires the `shap` package (included in core dependencies).
|
|
843
|
+
|
|
844
|
+
#### Examples:
|
|
845
|
+
```python
|
|
846
|
+
import gitlabds
|
|
847
|
+
|
|
848
|
+
shap_df = gitlabds.per_record_shap(
|
|
849
|
+
model=trained_model,
|
|
850
|
+
df=model_features,
|
|
851
|
+
feature_dict="feature_dict.csv",
|
|
852
|
+
)
|
|
853
|
+
```
|
|
854
|
+
|
|
855
|
+
</details>
|
|
856
|
+
|
|
857
|
+
<details><summary> Per-Record Counterfactuals </summary>
|
|
858
|
+
|
|
859
|
+
#### 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.
|
|
861
|
+
|
|
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, bands={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
863
|
+
|
|
864
|
+
#### Parameters:
|
|
865
|
+
- _**model**_ : trained model (as in `per_record_shap`)
|
|
866
|
+
- _**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
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
870
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
871
|
+
- _**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
|
+
- _**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
|
+
- _**bands**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that band, 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 band's cutoff must be `float("inf")`. Used to detect whether a move crosses a band 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
|
|
877
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
878
|
+
|
|
879
|
+
#### 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_band`
|
|
881
|
+
|
|
882
|
+
#### Requirements
|
|
883
|
+
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
884
|
+
|
|
885
|
+
#### Examples:
|
|
886
|
+
```python
|
|
887
|
+
import gitlabds
|
|
888
|
+
|
|
889
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
890
|
+
model=trained_model,
|
|
891
|
+
df=model_features,
|
|
892
|
+
shap_df=shap_df,
|
|
893
|
+
peer_group=peer_group_df,
|
|
894
|
+
)
|
|
895
|
+
```
|
|
896
|
+
|
|
897
|
+
</details>
|
|
898
|
+
|
|
899
|
+
<details><summary> Per-Record Peer Percentiles </summary>
|
|
900
|
+
|
|
901
|
+
#### Description
|
|
902
|
+
Peer benchmarking. Each record's percentile for each feature within its peer group, plus the group's p25/p50/p75 for context. Pure pandas -- no model required.
|
|
903
|
+
|
|
904
|
+
`gitlabds.per_record_peer_percentiles(df, feature_dict, peer_group=None, unique_id="record_id", actionable_values=["Yes", "Partial"], features=None, n_bins=5):`
|
|
905
|
+
|
|
906
|
+
#### Parameters:
|
|
907
|
+
- _**df**_ : model features DataFrame, indexed by `unique_id`
|
|
908
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
909
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
910
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
911
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
912
|
+
- _**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
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
914
|
+
|
|
915
|
+
#### Returns
|
|
916
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
917
|
+
|
|
918
|
+
#### Examples:
|
|
919
|
+
```python
|
|
920
|
+
import gitlabds
|
|
921
|
+
|
|
922
|
+
percentiles_df = gitlabds.per_record_peer_percentiles(
|
|
923
|
+
df=model_features,
|
|
924
|
+
feature_dict="feature_dict.csv",
|
|
925
|
+
peer_group=peer_group_df,
|
|
926
|
+
)
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
</details>
|
|
930
|
+
|
|
786
931
|
### Model Monitoring
|
|
787
932
|
|
|
788
933
|
<details><summary> Generate Baseline Features </summary>
|
|
@@ -1040,6 +1185,45 @@ print(f"Cleared {locks_cleared} locks")
|
|
|
1040
1185
|
|
|
1041
1186
|
</details>
|
|
1042
1187
|
|
|
1188
|
+
<details><summary> Get Feature Descriptions </summary>
|
|
1189
|
+
|
|
1190
|
+
#### Description
|
|
1191
|
+
Look up each feature's source feature view and description from the feature store's registered feature views (column comments set via `FeatureView.attach_feature_desc(...)`). Useful for documenting a model's fields or auditing everything registered in the store.
|
|
1192
|
+
|
|
1193
|
+
`gitlabds.get_feature_descriptions(session, feature_store, features=None):`
|
|
1194
|
+
|
|
1195
|
+
#### Parameters:
|
|
1196
|
+
- **_session_** : Active Snowpark session (e.g., `fs_engine._session`)
|
|
1197
|
+
- **_feature_store_** : Initialized FeatureStore instance (e.g., `fs_engine`); used to enumerate the registered feature views and locate their db/schema
|
|
1198
|
+
- **_features_** : Feature names to look up. If None, returns every feature in the store's registered feature views.
|
|
1199
|
+
|
|
1200
|
+
#### Returns
|
|
1201
|
+
- DataFrame with columns: `feature`, `feature_view`, `data_type`, `description`. Features found in more than one feature view have their views/descriptions joined (`" | "` / `" || "`) so nothing is dropped. When `features` is provided, unmatched features (e.g., transformed or dummy-coded features) come back with blank `feature_view` / `data_type` / `description` for the user to populate manually, and the caller's order/list is preserved.
|
|
1202
|
+
|
|
1203
|
+
#### Requirements
|
|
1204
|
+
This function requires Snowflake packages. Install with:
|
|
1205
|
+
```bash
|
|
1206
|
+
pip install snowflake-snowpark-python snowflake-ml-python
|
|
1207
|
+
```
|
|
1208
|
+
|
|
1209
|
+
#### Examples:
|
|
1210
|
+
|
|
1211
|
+
```python
|
|
1212
|
+
import gitlabds
|
|
1213
|
+
|
|
1214
|
+
# Look up a specific list (e.g. the model fields from a config)
|
|
1215
|
+
desc = gitlabds.get_feature_descriptions(
|
|
1216
|
+
session=fs_engine._session,
|
|
1217
|
+
feature_store=fs_engine,
|
|
1218
|
+
features=scoring_config["model"]["fields"],
|
|
1219
|
+
)
|
|
1220
|
+
|
|
1221
|
+
# Or pull every feature registered in the store
|
|
1222
|
+
all_desc = gitlabds.get_feature_descriptions(session=fs_engine._session, feature_store=fs_engine)
|
|
1223
|
+
```
|
|
1224
|
+
|
|
1225
|
+
</details>
|
|
1226
|
+
|
|
1043
1227
|
### SQL and Trend Analysis
|
|
1044
1228
|
|
|
1045
1229
|
<details><summary> SQL Trend Query Generator </summary>
|
|
@@ -3,6 +3,7 @@ from .config_generator import ConfigGenerator
|
|
|
3
3
|
from .dummy import dummy_code, dummy_top, apply_dummy
|
|
4
4
|
from .feature_reduction import drop_categorical, remove_low_variation, remove_outcome_proxies, correlation_reduction
|
|
5
5
|
from .insights import marginal_effects, prescriptions
|
|
6
|
+
from .record_benchmarking import value_vs_peer_status, per_record_shap, per_record_counterfactuals, per_record_peer_percentiles
|
|
6
7
|
from .memory_optimization import reduce_memory_usage, sparse_encode_df, identify_categorical_candidates, compact_categorical, memory_profile_df, memory_optimization
|
|
7
8
|
from .missing import missing_values, apply_missing_values
|
|
8
9
|
from .model_evaluator import ModelEvaluator
|
|
@@ -14,6 +15,7 @@ from .trends import generate_sql_trend_query, trend_analysis
|
|
|
14
15
|
# Optional imports for Snowflake feature serving
|
|
15
16
|
try:
|
|
16
17
|
from .serving_features import clear_feature_serving_locks, serve_features
|
|
18
|
+
from .feature_descriptions import get_feature_descriptions
|
|
17
19
|
except ImportError as e:
|
|
18
20
|
# Snowflake packages not installed - create placeholder functions with helpful error messages
|
|
19
21
|
def _snowflake_not_installed(*args, **kwargs):
|
|
@@ -21,7 +23,8 @@ except ImportError as e:
|
|
|
21
23
|
"Snowflake feature serving functions require additional dependencies. "
|
|
22
24
|
"Install them with: pip install snowflake-snowpark-python snowflake-ml-python\n\n"
|
|
23
25
|
)
|
|
24
|
-
|
|
26
|
+
|
|
25
27
|
clear_feature_serving_locks = _snowflake_not_installed
|
|
26
28
|
serve_features = _snowflake_not_installed
|
|
29
|
+
get_feature_descriptions = _snowflake_not_installed
|
|
27
30
|
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Feature description lookup for Snowflake Feature Store integration.
|
|
3
|
+
|
|
4
|
+
Given a list of feature names, return each feature's source feature view and its
|
|
5
|
+
description (pulled from the registered feature views' column comments, which is
|
|
6
|
+
where FeatureView.attach_feature_desc(...) stores them). Follows the same call
|
|
7
|
+
pattern as gitlabds.serve_features: pass the feature-store session and object.
|
|
8
|
+
|
|
9
|
+
Note: This module requires snowflake-snowpark-python and snowflake-ml-python packages.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from typing import Iterable, Optional
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
# Optional imports with graceful fallback
|
|
16
|
+
try:
|
|
17
|
+
from snowflake.snowpark import Session
|
|
18
|
+
from snowflake.ml.feature_store import FeatureStore
|
|
19
|
+
SNOWFLAKE_AVAILABLE = True
|
|
20
|
+
except ImportError:
|
|
21
|
+
SNOWFLAKE_AVAILABLE = False
|
|
22
|
+
Session = None
|
|
23
|
+
FeatureStore = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_snowflake_available():
|
|
27
|
+
"""Check if Snowflake packages are available."""
|
|
28
|
+
if not SNOWFLAKE_AVAILABLE:
|
|
29
|
+
raise ImportError(
|
|
30
|
+
"Snowflake packages are required for feature descriptions. "
|
|
31
|
+
"Install with: pip install snowflake-snowpark-python snowflake-ml-python"
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def get_feature_descriptions(
|
|
36
|
+
session: 'Session',
|
|
37
|
+
feature_store: 'FeatureStore',
|
|
38
|
+
features: Optional[Iterable[str]] = None,
|
|
39
|
+
) -> pd.DataFrame:
|
|
40
|
+
"""
|
|
41
|
+
Look up each feature's source feature view and description from the feature
|
|
42
|
+
store's registered feature views (column comments set via
|
|
43
|
+
FeatureView.attach_feature_desc(...)).
|
|
44
|
+
|
|
45
|
+
Parameters
|
|
46
|
+
----------
|
|
47
|
+
session : snowflake.snowpark.Session
|
|
48
|
+
Active Snowpark session (e.g., fs_engine._session).
|
|
49
|
+
feature_store : snowflake.ml.feature_store.FeatureStore
|
|
50
|
+
Initialized FeatureStore instance (e.g., fs_engine); used to enumerate the
|
|
51
|
+
registered feature views and to locate their db/schema.
|
|
52
|
+
features : Iterable[str], optional
|
|
53
|
+
Feature names to look up. If None, returns every feature in the store's
|
|
54
|
+
registered feature views.
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
pd.DataFrame
|
|
59
|
+
Columns: [feature, feature_view, data_type, description]. Features found
|
|
60
|
+
in more than one feature view have their views/descriptions joined
|
|
61
|
+
(" | " / " || ") so nothing is dropped. When `features` is provided,
|
|
62
|
+
unmatched features (e.g., transformed or dummy-coded features) come back
|
|
63
|
+
with blank feature_view / data_type / description for the user to
|
|
64
|
+
populate manually, and order/list is preserved.
|
|
65
|
+
"""
|
|
66
|
+
_check_snowflake_available()
|
|
67
|
+
|
|
68
|
+
# Registered feature views -> exact published table names (e.g. ACCOUNT_CI_USAGE$1.0).
|
|
69
|
+
fvs = feature_store.list_feature_views().select("NAME", "VERSION").to_pandas()
|
|
70
|
+
tables = [f"{n}${v}" for n, v in zip(fvs["NAME"], fvs["VERSION"])]
|
|
71
|
+
|
|
72
|
+
# Database/schema come from the live feature-store session, not hard-coded.
|
|
73
|
+
# Pulled directly rather than parsed out of get_fully_qualified_current_schema(),
|
|
74
|
+
# which would require quote-aware splitting to handle identifiers containing ".".
|
|
75
|
+
database = session.get_current_database().strip('"')
|
|
76
|
+
schema = session.get_current_schema().strip('"')
|
|
77
|
+
|
|
78
|
+
def sql_list(values):
|
|
79
|
+
return ",".join("'" + str(v).replace("'", "''") + "'" for v in values)
|
|
80
|
+
|
|
81
|
+
meta = session.sql(
|
|
82
|
+
"SELECT TABLE_NAME, COLUMN_NAME, DATA_TYPE, COMMENT "
|
|
83
|
+
f"FROM {database}.INFORMATION_SCHEMA.COLUMNS "
|
|
84
|
+
f"WHERE TABLE_SCHEMA = '{schema}' AND TABLE_NAME IN ({sql_list(tables)})"
|
|
85
|
+
).to_pandas()
|
|
86
|
+
meta.columns = [c.upper() for c in meta.columns]
|
|
87
|
+
|
|
88
|
+
meta["feature_view"] = meta["TABLE_NAME"].str.split("$").str[0]
|
|
89
|
+
meta["feature"] = meta["COLUMN_NAME"].str.lower()
|
|
90
|
+
|
|
91
|
+
catalog = meta.groupby("feature", as_index=False).agg(
|
|
92
|
+
feature_view=("feature_view", lambda s: " | ".join(sorted(set(s)))),
|
|
93
|
+
data_type=("DATA_TYPE", lambda s: " | ".join(sorted(set(s.dropna().astype(str))))),
|
|
94
|
+
description=("COMMENT", lambda s: " || ".join(dict.fromkeys(x for x in s if x))),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if features is None:
|
|
98
|
+
return catalog.sort_values("feature").reset_index(drop=True)
|
|
99
|
+
|
|
100
|
+
# Preserve the caller's feature list/order; blanks where unmatched.
|
|
101
|
+
requested = pd.DataFrame({"feature": list(features)})
|
|
102
|
+
requested["_key"] = requested["feature"].str.lower()
|
|
103
|
+
out = requested.merge(
|
|
104
|
+
catalog.rename(columns={"feature": "_key"}), on="_key", how="left"
|
|
105
|
+
).drop(columns="_key")
|
|
106
|
+
return out[["feature", "feature_view", "data_type", "description"]].fillna("")
|
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Per-record model insight utilities.
|
|
3
|
+
|
|
4
|
+
These functions benchmark, explain, and prescribe changes for any indexed set of
|
|
5
|
+
scored records (accounts, users, deals, transactions, etc.) -- `unique_id` is just
|
|
6
|
+
whichever column identifies a row in your data. Includes:
|
|
7
|
+
|
|
8
|
+
- value_vs_peer_status : level label for a value vs. its peer median (+/- band)
|
|
9
|
+
- per_record_shap : per-record SHAP attribution (log-odds/margin space) for a tree model
|
|
10
|
+
- per_record_counterfactuals : per-record "change X -> +Y" counterfactual search
|
|
11
|
+
- per_record_peer_percentiles : per-record percentile standing within a peer group
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import pandas as pd
|
|
15
|
+
import numpy as np
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15,
|
|
19
|
+
no_activity_value=None, peer_percentile=None, ceiling_pct=0.95,
|
|
20
|
+
ceiling_label="near ceiling",
|
|
21
|
+
labels=("below average", "average", "above average", "No activity")):
|
|
22
|
+
"""
|
|
23
|
+
Status from value vs. its peer median (+/- tolerance), not percentile rank.
|
|
24
|
+
|
|
25
|
+
value : value(s) to evaluate, scalar or array-like.
|
|
26
|
+
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.
|
|
30
|
+
tolerance : relative distance from the peer median (as a fraction of the median)
|
|
31
|
+
required to be classified above/below average rather than "average". E.g.
|
|
32
|
+
the default 0.15 means a value must be >15% above (or below) its peer median
|
|
33
|
+
to leave the "average" band.
|
|
34
|
+
no_activity_value : value that should always map to labels[3] ("No activity")
|
|
35
|
+
regardless of tolerance, in addition to actual missing values.
|
|
36
|
+
peer_percentile : optional 0-1 percentile rank within the peer group. When given,
|
|
37
|
+
an "above average" reading that also sits in the top ceiling_pct toward
|
|
38
|
+
good_direction is upgraded to ceiling_label -- a tapped-out strength, not a
|
|
39
|
+
lever, rather than just "above average".
|
|
40
|
+
ceiling_pct : percentile threshold (toward good_direction) used with
|
|
41
|
+
peer_percentile to detect the ceiling condition above.
|
|
42
|
+
ceiling_label : label applied when the ceiling condition is met.
|
|
43
|
+
labels : (below average, average, above average, no activity) labels to use
|
|
44
|
+
in place of the defaults.
|
|
45
|
+
|
|
46
|
+
Returns a Series of level labels, one per input value.
|
|
47
|
+
"""
|
|
48
|
+
value = pd.Series(value)
|
|
49
|
+
# index=value.index (not .reindex()) so a bare scalar broadcasts correctly.
|
|
50
|
+
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)
|
|
53
|
+
eff_value = value.where(~decreasing, -value)
|
|
54
|
+
eff_median = peer_median.where(~decreasing, -peer_median)
|
|
55
|
+
denom = eff_median.abs()
|
|
56
|
+
with np.errstate(divide="ignore", invalid="ignore"):
|
|
57
|
+
rel_diff = (eff_value - eff_median) / denom
|
|
58
|
+
rel_diff = rel_diff.where(denom > 0, np.sign(eff_value - eff_median))
|
|
59
|
+
level = pd.Series(np.select([rel_diff > tolerance, rel_diff < -tolerance],
|
|
60
|
+
[labels[2], labels[0]], default=labels[1]),
|
|
61
|
+
index=value.index)
|
|
62
|
+
# "near ceiling": already above the peer median AND in the top ceiling_pct of the peer
|
|
63
|
+
# group toward good_direction. Gating on labels[2] (above average) means a compressed
|
|
64
|
+
# peer group whose top decile is still ~median is left "average", not falsely capped.
|
|
65
|
+
# Applied before the null / no-activity masks below so those still take precedence.
|
|
66
|
+
if peer_percentile is not None:
|
|
67
|
+
pct = pd.Series(peer_percentile, index=value.index).astype(float)
|
|
68
|
+
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)
|
|
70
|
+
level = level.mask(value.isna(), labels[3])
|
|
71
|
+
if no_activity_value is not None:
|
|
72
|
+
no_activity_value = pd.Series(no_activity_value, index=value.index)
|
|
73
|
+
level = level.where(value != no_activity_value, labels[3])
|
|
74
|
+
return level
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def per_record_shap(
|
|
78
|
+
model,
|
|
79
|
+
df,
|
|
80
|
+
feature_dict,
|
|
81
|
+
unique_id="record_id",
|
|
82
|
+
top_n=25,
|
|
83
|
+
min_actionable=3,
|
|
84
|
+
actionable_values=["Yes", "Partial"],
|
|
85
|
+
):
|
|
86
|
+
"""
|
|
87
|
+
Per-record SHAP attribution (log-odds / margin space) for a tree model.
|
|
88
|
+
Uses tree_path_dependent TreeSHAP, which natively handles the model's categorical
|
|
89
|
+
splits (probability-space SHAP requires a numeric interventional background and is not
|
|
90
|
+
available for LightGBM models with native categorical features). Contributions are in
|
|
91
|
+
log-odds: `+` pushes the record's score above the base rate, `-` below; magnitude ranks
|
|
92
|
+
the drivers.
|
|
93
|
+
|
|
94
|
+
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_`).
|
|
96
|
+
df : model-ready feature matrix, indexed by unique_id.
|
|
97
|
+
feature_dict : DataFrame, or path to a CSV, with 'field' and 'actionable' columns
|
|
98
|
+
mapping each feature to whether/how it can be acted on.
|
|
99
|
+
unique_id : name to use for the record identifier column in the output.
|
|
100
|
+
top_n : number of features to return per record, ranked by |contribution|.
|
|
101
|
+
min_actionable : minimum number of actionable features guaranteed per record,
|
|
102
|
+
pulled in from outside the top_n if needed.
|
|
103
|
+
actionable_values : values in feature_dict["actionable"] treated as actionable.
|
|
104
|
+
|
|
105
|
+
Returns a long DataFrame, one row per (record, feature): unique_id, rank, feature,
|
|
106
|
+
contribution, direction, feature_value, actionable.
|
|
107
|
+
"""
|
|
108
|
+
import shap, warnings
|
|
109
|
+
if isinstance(feature_dict, str):
|
|
110
|
+
feature_dict = pd.read_csv(feature_dict)
|
|
111
|
+
feats = list(df.columns)
|
|
112
|
+
ids = df.index
|
|
113
|
+
booster = getattr(model, "booster_", model)
|
|
114
|
+
with warnings.catch_warnings(): # silence shap's informational list-output notice
|
|
115
|
+
warnings.simplefilter("ignore")
|
|
116
|
+
sv = shap.TreeExplainer(booster, feature_perturbation="tree_path_dependent").shap_values(df)
|
|
117
|
+
if isinstance(sv, list): # binary classifiers return [class0, class1]
|
|
118
|
+
sv = sv[-1]
|
|
119
|
+
sv = np.asarray(sv)
|
|
120
|
+
if sv.ndim == 3:
|
|
121
|
+
sv = sv[:, :, -1]
|
|
122
|
+
contrib = pd.DataFrame(sv, columns=feats)
|
|
123
|
+
c = contrib.assign(**{unique_id: ids}).melt(unique_id, var_name="feature", value_name="contribution")
|
|
124
|
+
v = df.reset_index(drop=True).assign(**{unique_id: ids}).melt(unique_id, var_name="feature", value_name="feature_value")
|
|
125
|
+
d = c.merge(v, on=[unique_id, "feature"])
|
|
126
|
+
d["abs_contribution"] = d["contribution"].abs()
|
|
127
|
+
act = dict(zip(feature_dict["field"], feature_dict["actionable"]))
|
|
128
|
+
d["actionable"] = d["feature"].map(act)
|
|
129
|
+
d["is_actionable"] = d["actionable"].isin(actionable_values)
|
|
130
|
+
# Top_n per record by |contribution|, guaranteeing >= min_actionable actionable.
|
|
131
|
+
d["pool_rank"] = d.groupby(unique_id, observed=True)["abs_contribution"].rank(method="first", ascending=False)
|
|
132
|
+
in_top = d["pool_rank"] <= top_n
|
|
133
|
+
act_in_top = (d[in_top & d["is_actionable"]].groupby(unique_id, observed=True).size()
|
|
134
|
+
.reindex(pd.unique(ids), fill_value=0))
|
|
135
|
+
need = (min_actionable - act_in_top).clip(lower=0)
|
|
136
|
+
outside = d[(~in_top) & d["is_actionable"]].copy()
|
|
137
|
+
outside["rec_rank"] = outside.groupby(unique_id, observed=True)["abs_contribution"].rank(method="first", ascending=False)
|
|
138
|
+
outside = outside.merge(need.rename("need"), left_on=unique_id, right_index=True)
|
|
139
|
+
keep = d.loc[d.index[in_top].union(outside.index[outside["rec_rank"] <= outside["need"]])].copy()
|
|
140
|
+
keep["direction"] = np.where(keep["contribution"] >= 0, "increases", "decreases")
|
|
141
|
+
keep["rank"] = keep.groupby(unique_id, observed=True)["abs_contribution"].rank(method="first", ascending=False).astype(int)
|
|
142
|
+
# abs_contribution stays internal (used above for pool_rank/rec_rank/rank) — not in the
|
|
143
|
+
# final output, since nothing downstream reads it: rank already gives the ordering, and
|
|
144
|
+
# contribution itself already carries the magnitude.
|
|
145
|
+
cols = [unique_id, "rank", "feature", "contribution",
|
|
146
|
+
"direction", "feature_value", "actionable"]
|
|
147
|
+
return keep.sort_values([unique_id, "rank"]).reset_index(drop=True)[cols]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def per_record_counterfactuals(
|
|
151
|
+
model,
|
|
152
|
+
df,
|
|
153
|
+
shap_df,
|
|
154
|
+
model_algo="lgb",
|
|
155
|
+
unique_id="record_id",
|
|
156
|
+
peer_group=None,
|
|
157
|
+
candidate_quantiles=[0.1, 0.25, 0.5, 0.75, 0.9],
|
|
158
|
+
direction=None,
|
|
159
|
+
actionable_values=["Yes", "Partial"],
|
|
160
|
+
min_delta_p=0.005,
|
|
161
|
+
impact_medium=0.01,
|
|
162
|
+
impact_high=0.03,
|
|
163
|
+
bands={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")},
|
|
164
|
+
n_bins=5,
|
|
165
|
+
):
|
|
166
|
+
"""
|
|
167
|
+
"change X -> +Y", direction chosen empirically.
|
|
168
|
+
For each actionable lever SHAP surfaced for a record, evaluate a set of candidate
|
|
169
|
+
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.
|
|
173
|
+
|
|
174
|
+
model, df, model_algo, unique_id : as in per_record_shap (df indexed by unique_id).
|
|
175
|
+
shap_df : per_record_shap output; scopes which (record, feature) levers to test.
|
|
176
|
+
peer_group : Series/DataFrame indexed by unique_id; numeric columns auto-binned into
|
|
177
|
+
n_bins quantile bands. None -> one global group. Candidate values are drawn
|
|
178
|
+
from each feature's distribution within the record's peer group.
|
|
179
|
+
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
|
|
185
|
+
the probability distribution, so a fixed pp threshold alone under-weights
|
|
186
|
+
moves for low-scoring/mid-band records relative to those near a band edge.
|
|
187
|
+
bands (below) corrects for that: any move that crosses a band boundary is
|
|
188
|
+
floored at "Medium" regardless of its raw delta_p, since crossing bands is
|
|
189
|
+
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;
|
|
191
|
+
bands should mirror whatever thresholds are live in your own scoring/banding
|
|
192
|
+
logic (if that logic considers factors beyond the score itself, bands are
|
|
193
|
+
only meant to detect crossings for impact_bucket purposes, not to reproduce
|
|
194
|
+
it exactly).
|
|
195
|
+
bands : dict mapping label -> upper bound (on p_before/p_after) for that band,
|
|
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 -- bands are
|
|
199
|
+
sorted by their cutoff value -- but the highest-scoring band's cutoff must be
|
|
200
|
+
float("inf") since it has no upper bound. Used only to detect band crossings
|
|
201
|
+
for impact_bucket, not to reproduce your production banding logic. Pass your
|
|
202
|
+
own dict (e.g. {"low": 0.03, "medium": 0.10, "high": float("inf")}) to match
|
|
203
|
+
your own pipeline.
|
|
204
|
+
n_bins : quantile bins for numeric peer_group columns.
|
|
205
|
+
|
|
206
|
+
Returns a long DataFrame: one row per (record, lever) with the best move found
|
|
207
|
+
(current_value, intervention_value, intervention_quantile, p_before/p_after/delta_p,
|
|
208
|
+
impact_bucket, level_before, level_after, crosses_band).
|
|
209
|
+
"""
|
|
210
|
+
if impact_medium >= impact_high:
|
|
211
|
+
raise ValueError(f"impact_medium ({impact_medium}) must be less than impact_high ({impact_high})")
|
|
212
|
+
ids = df.index
|
|
213
|
+
# Peer-group key: numeric peer columns auto-binned into n_bins quantile bands.
|
|
214
|
+
if peer_group is None:
|
|
215
|
+
grp = pd.Series("all", index=ids)
|
|
216
|
+
else:
|
|
217
|
+
pk = peer_group.reindex(ids)
|
|
218
|
+
pk = pk.to_frame() if isinstance(pk, pd.Series) else pk
|
|
219
|
+
key = None
|
|
220
|
+
for c in pk.columns:
|
|
221
|
+
band = (pd.qcut(pk[c], q=n_bins, duplicates="drop").astype(str)
|
|
222
|
+
if pd.api.types.is_numeric_dtype(pk[c]) else pk[c].astype(str))
|
|
223
|
+
key = band if key is None else key.str.cat(band, sep=" | ")
|
|
224
|
+
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")
|
|
231
|
+
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]
|
|
237
|
+
p0 = pd.Series(proba(df), index=ids) # unclipped baseline
|
|
238
|
+
levers = shap_df[shap_df["actionable"].isin(actionable_values)]
|
|
239
|
+
records_by_feat = levers.groupby("feature")[unique_id].apply(set)
|
|
240
|
+
Xw = df.copy() # single reusable working matrix
|
|
241
|
+
parts = []
|
|
242
|
+
for f, recs in records_by_feat.items():
|
|
243
|
+
if f not in df.columns:
|
|
244
|
+
continue
|
|
245
|
+
cur = df[f]
|
|
246
|
+
in_scope = pd.Series(ids.isin(recs), index=ids)
|
|
247
|
+
allowed = direction.get(f, "both")
|
|
248
|
+
orig = df[f].values
|
|
249
|
+
for q in candidate_quantiles:
|
|
250
|
+
cand = df.groupby(grp)[f].transform(lambda s: s.quantile(q))
|
|
251
|
+
move = in_scope & cand.notna() & (cand != cur)
|
|
252
|
+
if allowed == "up":
|
|
253
|
+
move &= cand > cur
|
|
254
|
+
elif allowed == "down":
|
|
255
|
+
move &= cand < cur
|
|
256
|
+
if not move.any():
|
|
257
|
+
continue
|
|
258
|
+
Xw[f] = np.where(move.values, cand.values, orig)
|
|
259
|
+
p1 = pd.Series(proba(Xw), index=ids)
|
|
260
|
+
m = move.values
|
|
261
|
+
parts.append(pd.DataFrame({
|
|
262
|
+
unique_id: ids[m],
|
|
263
|
+
"feature": f,
|
|
264
|
+
"current_value": cur[m].values,
|
|
265
|
+
"intervention_value": cand[m].values,
|
|
266
|
+
"intervention_quantile": q,
|
|
267
|
+
"peer_group": grp[m].values,
|
|
268
|
+
"p_before": p0[m].values,
|
|
269
|
+
"p_after": p1[m].values,
|
|
270
|
+
"delta_p": (p1 - p0)[m].values,
|
|
271
|
+
}))
|
|
272
|
+
Xw[f] = orig # restore before next feature
|
|
273
|
+
if not parts:
|
|
274
|
+
return pd.DataFrame()
|
|
275
|
+
out = pd.concat(parts, ignore_index=True)
|
|
276
|
+
# Keep the single best-scoring candidate per (record, feature).
|
|
277
|
+
out = (out.sort_values("delta_p", ascending=False)
|
|
278
|
+
.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"],
|
|
283
|
+
bins=[-np.inf, impact_medium, impact_high, np.inf],
|
|
284
|
+
labels=["Low", "Medium", "High"]).astype(str)
|
|
285
|
+
# Band-crossing floor: a move that pushes the record into a better band is at
|
|
286
|
+
# least "Medium" impact even if its raw delta_p is small (see docstring).
|
|
287
|
+
_sorted_bands = sorted(bands.items(), key=lambda kv: kv[1])
|
|
288
|
+
_edges = [-np.inf] + [cutoff for _, cutoff in _sorted_bands]
|
|
289
|
+
_labels = [label for label, _ in _sorted_bands]
|
|
290
|
+
def _band(p):
|
|
291
|
+
return pd.cut(p, bins=_edges, labels=_labels).astype(str)
|
|
292
|
+
out["level_before"] = _band(out["p_before"])
|
|
293
|
+
out["level_after"] = _band(out["p_after"])
|
|
294
|
+
out["crosses_level"] = out["level_before"] != out["level_after"]
|
|
295
|
+
out.loc[out["crosses_band"] & (out["impact_bucket"] == "Low"), "impact_bucket"] = "Medium"
|
|
296
|
+
return out.sort_values([unique_id, "delta_p"], ascending=[True, False]).reset_index(drop=True)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def per_record_peer_percentiles(
|
|
300
|
+
df,
|
|
301
|
+
feature_dict,
|
|
302
|
+
peer_group=None,
|
|
303
|
+
unique_id="record_id",
|
|
304
|
+
actionable_values=["Yes", "Partial"],
|
|
305
|
+
features=None,
|
|
306
|
+
n_bins=5,
|
|
307
|
+
):
|
|
308
|
+
"""
|
|
309
|
+
Peer benchmarking. Each record's percentile for each feature within its
|
|
310
|
+
peer group, plus the group's p25/p50/p75 for context. Pure pandas (no model, no LLM).
|
|
311
|
+
|
|
312
|
+
df : model features df indexed by unique_id.
|
|
313
|
+
feature_dict : DataFrame/path; uses 'field' + 'actionable'.
|
|
314
|
+
peer_group : Series/DataFrame indexed by unique_id; numeric columns auto-binned into
|
|
315
|
+
n_bins quantile bands. None -> one global group.
|
|
316
|
+
features : optional explicit list. If given, used as-is (just needs to be a column
|
|
317
|
+
in df) -- no actionable filter, since an explicit request means you know what
|
|
318
|
+
you want, including non-actionable descriptive fields.
|
|
319
|
+
If None (default), computed for every actionable model feature in df.
|
|
320
|
+
|
|
321
|
+
Returns a long DataFrame: one row per (record, feature).
|
|
322
|
+
"""
|
|
323
|
+
if isinstance(feature_dict, str):
|
|
324
|
+
feature_dict = pd.read_csv(feature_dict)
|
|
325
|
+
if features is not None:
|
|
326
|
+
cols = [f for f in features if f in df.columns]
|
|
327
|
+
else:
|
|
328
|
+
act_fields = set(feature_dict.loc[feature_dict["actionable"].isin(actionable_values), "field"])
|
|
329
|
+
cols = [f for f in df.columns if f in act_fields]
|
|
330
|
+
ids = df.index
|
|
331
|
+
# Peer-group key: numeric peer columns auto-binned into n_bins quantile bands.
|
|
332
|
+
if peer_group is None:
|
|
333
|
+
grp = pd.Series("all", index=ids)
|
|
334
|
+
else:
|
|
335
|
+
pk = peer_group.reindex(ids)
|
|
336
|
+
pk = pk.to_frame() if isinstance(pk, pd.Series) else pk
|
|
337
|
+
key = None
|
|
338
|
+
for c in pk.columns:
|
|
339
|
+
band = (pd.qcut(pk[c], q=n_bins, duplicates="drop").astype(str)
|
|
340
|
+
if pd.api.types.is_numeric_dtype(pk[c]) else pk[c].astype(str))
|
|
341
|
+
key = band if key is None else key.str.cat(band, sep=" | ")
|
|
342
|
+
grp = pd.Series(np.asarray(key), index=ids)
|
|
343
|
+
parts = []
|
|
344
|
+
for f in cols:
|
|
345
|
+
g = df.groupby(grp)[f]
|
|
346
|
+
parts.append(pd.DataFrame({
|
|
347
|
+
unique_id: ids,
|
|
348
|
+
"feature": f,
|
|
349
|
+
"record_value": df[f].values,
|
|
350
|
+
"peer_group": grp.values,
|
|
351
|
+
"percentile": g.rank(pct=True).values,
|
|
352
|
+
"peer_p25": g.transform(lambda s: s.quantile(0.25)).values,
|
|
353
|
+
"peer_p50": g.transform(lambda s: s.quantile(0.50)).values,
|
|
354
|
+
"peer_p75": g.transform(lambda s: s.quantile(0.75)).values,
|
|
355
|
+
}))
|
|
356
|
+
if not parts:
|
|
357
|
+
return pd.DataFrame(columns=[unique_id, "feature", "record_value", "peer_group",
|
|
358
|
+
"percentile", "peer_p25", "peer_p50", "peer_p75"])
|
|
359
|
+
out = pd.concat(parts, ignore_index=True)
|
|
360
|
+
return out.sort_values([unique_id, "feature"]).reset_index(drop=True)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitlabds
|
|
3
|
-
Version: 2.1
|
|
3
|
+
Version: 2.2.1
|
|
4
4
|
Summary: GitLab Data Science Tools
|
|
5
5
|
Author-email: Kevin Dietz <kdietz@gitlab.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -63,7 +63,7 @@ pip install gitlabds
|
|
|
63
63
|
|
|
64
64
|
#### Snowflake Feature Store
|
|
65
65
|
|
|
66
|
-
The `serve_features` and `
|
|
66
|
+
The `serve_features`, `clear_feature_serving_locks`, and `get_feature_descriptions` functions require additional Snowflake dependencies.
|
|
67
67
|
|
|
68
68
|
To use these functions, install with:
|
|
69
69
|
|
|
@@ -815,6 +815,151 @@ results = gitlabds.prescriptions(
|
|
|
815
815
|
```
|
|
816
816
|
</details>
|
|
817
817
|
|
|
818
|
+
### Record Benchmarking
|
|
819
|
+
|
|
820
|
+
<details><summary> Value vs. Peer Status </summary>
|
|
821
|
+
|
|
822
|
+
#### Description
|
|
823
|
+
Status label for a value vs. its peer median (+/- tolerance), not percentile rank.
|
|
824
|
+
|
|
825
|
+
`gitlabds.value_vs_peer_status(value, peer_median, good_direction=None, tolerance=0.15, no_activity_value=None, peer_percentile=None, ceiling_pct=0.95, ceiling_label="near ceiling", labels=("below average", "average", "above average", "No activity")):`
|
|
826
|
+
|
|
827
|
+
#### Parameters:
|
|
828
|
+
- _**value**_ : value(s) to evaluate, scalar or array-like
|
|
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.
|
|
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
|
+
- _**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`
|
|
834
|
+
- _**ceiling_pct**_ : percentile threshold used with `peer_percentile` to detect a "tapped out" strength. Default is 0.95
|
|
835
|
+
- _**ceiling_label**_ : label applied when the ceiling condition is met. Default is "near ceiling"
|
|
836
|
+
- _**labels**_ : tuple of (below average, average, above average, no activity) labels
|
|
837
|
+
|
|
838
|
+
#### Returns
|
|
839
|
+
- Series of level labels, one per input value
|
|
840
|
+
|
|
841
|
+
#### Examples:
|
|
842
|
+
```python
|
|
843
|
+
import gitlabds
|
|
844
|
+
|
|
845
|
+
level = gitlabds.value_vs_peer_status(
|
|
846
|
+
value=df["monthly_spend"],
|
|
847
|
+
peer_median=df["peer_median_spend"],
|
|
848
|
+
peer_percentile=df["peer_percentile_spend"],
|
|
849
|
+
)
|
|
850
|
+
```
|
|
851
|
+
|
|
852
|
+
</details>
|
|
853
|
+
|
|
854
|
+
<details><summary> Per-Record SHAP </summary>
|
|
855
|
+
|
|
856
|
+
#### Description
|
|
857
|
+
Per-record SHAP attribution (log-odds / margin space) for a tree model. Uses tree_path_dependent TreeSHAP, which natively handles the model's categorical splits. Returns the top_n features by |contribution| per record, guaranteeing at least min_actionable actionable ones.
|
|
858
|
+
|
|
859
|
+
`gitlabds.per_record_shap(model, df, feature_dict, unique_id="record_id", top_n=25, min_actionable=3, actionable_values=["Yes", "Partial"]):`
|
|
860
|
+
|
|
861
|
+
#### Parameters:
|
|
862
|
+
- _**model**_ : trained tree-based model (e.g., LightGBM, XGBoost)
|
|
863
|
+
- _**df**_ : model-ready feature matrix, indexed by `unique_id`
|
|
864
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
865
|
+
- _**unique_id**_ : name to use for the record identifier column in the output. Default is "record_id"
|
|
866
|
+
- _**top_n**_ : number of features to return per record, ranked by |contribution|. Default is 25
|
|
867
|
+
- _**min_actionable**_ : minimum number of actionable features guaranteed per record, even if outside the top_n. Default is 3
|
|
868
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
869
|
+
|
|
870
|
+
#### Returns
|
|
871
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `rank`, `feature`, `contribution`, `direction`, `feature_value`, `actionable`
|
|
872
|
+
|
|
873
|
+
#### Requirements
|
|
874
|
+
This function requires the `shap` package (included in core dependencies).
|
|
875
|
+
|
|
876
|
+
#### Examples:
|
|
877
|
+
```python
|
|
878
|
+
import gitlabds
|
|
879
|
+
|
|
880
|
+
shap_df = gitlabds.per_record_shap(
|
|
881
|
+
model=trained_model,
|
|
882
|
+
df=model_features,
|
|
883
|
+
feature_dict="feature_dict.csv",
|
|
884
|
+
)
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
</details>
|
|
888
|
+
|
|
889
|
+
<details><summary> Per-Record Counterfactuals </summary>
|
|
890
|
+
|
|
891
|
+
#### 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.
|
|
893
|
+
|
|
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, bands={"poor fit": 0.03, "good fit": 0.10, "best fit": float("inf")}, n_bins=5):`
|
|
895
|
+
|
|
896
|
+
#### Parameters:
|
|
897
|
+
- _**model**_ : trained model (as in `per_record_shap`)
|
|
898
|
+
- _**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
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
902
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
903
|
+
- _**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
|
+
- _**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
|
+
- _**bands**_ : dict mapping label -> upper bound on `p_before`/`p_after` for that band, 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 band's cutoff must be `float("inf")`. Used to detect whether a move crosses a band 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
|
|
909
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
910
|
+
|
|
911
|
+
#### 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_band`
|
|
913
|
+
|
|
914
|
+
#### Requirements
|
|
915
|
+
For XGBoost models without `predict_proba`, requires the `xgboost` package (included in core dependencies).
|
|
916
|
+
|
|
917
|
+
#### Examples:
|
|
918
|
+
```python
|
|
919
|
+
import gitlabds
|
|
920
|
+
|
|
921
|
+
counterfactuals_df = gitlabds.per_record_counterfactuals(
|
|
922
|
+
model=trained_model,
|
|
923
|
+
df=model_features,
|
|
924
|
+
shap_df=shap_df,
|
|
925
|
+
peer_group=peer_group_df,
|
|
926
|
+
)
|
|
927
|
+
```
|
|
928
|
+
|
|
929
|
+
</details>
|
|
930
|
+
|
|
931
|
+
<details><summary> Per-Record Peer Percentiles </summary>
|
|
932
|
+
|
|
933
|
+
#### Description
|
|
934
|
+
Peer benchmarking. Each record's percentile for each feature within its peer group, plus the group's p25/p50/p75 for context. Pure pandas -- no model required.
|
|
935
|
+
|
|
936
|
+
`gitlabds.per_record_peer_percentiles(df, feature_dict, peer_group=None, unique_id="record_id", actionable_values=["Yes", "Partial"], features=None, n_bins=5):`
|
|
937
|
+
|
|
938
|
+
#### Parameters:
|
|
939
|
+
- _**df**_ : model features DataFrame, indexed by `unique_id`
|
|
940
|
+
- _**feature_dict**_ : DataFrame or path to CSV with `field` and `actionable` columns
|
|
941
|
+
- _**peer_group**_ : Series/DataFrame indexed by `unique_id`; numeric columns are auto-binned into `n_bins` quantile bands. `None` uses one global group
|
|
942
|
+
- _**unique_id**_ : name of the record identifier column. Default is "record_id"
|
|
943
|
+
- _**actionable_values**_ : values in `feature_dict["actionable"]` treated as actionable
|
|
944
|
+
- _**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
|
+
- _**n_bins**_ : quantile bins for numeric `peer_group` columns
|
|
946
|
+
|
|
947
|
+
#### Returns
|
|
948
|
+
- Long DataFrame, one row per (record, feature): `unique_id`, `feature`, `record_value`, `peer_group`, `percentile`, `peer_p25`, `peer_p50`, `peer_p75`
|
|
949
|
+
|
|
950
|
+
#### Examples:
|
|
951
|
+
```python
|
|
952
|
+
import gitlabds
|
|
953
|
+
|
|
954
|
+
percentiles_df = gitlabds.per_record_peer_percentiles(
|
|
955
|
+
df=model_features,
|
|
956
|
+
feature_dict="feature_dict.csv",
|
|
957
|
+
peer_group=peer_group_df,
|
|
958
|
+
)
|
|
959
|
+
```
|
|
960
|
+
|
|
961
|
+
</details>
|
|
962
|
+
|
|
818
963
|
### Model Monitoring
|
|
819
964
|
|
|
820
965
|
<details><summary> Generate Baseline Features </summary>
|
|
@@ -1072,6 +1217,45 @@ print(f"Cleared {locks_cleared} locks")
|
|
|
1072
1217
|
|
|
1073
1218
|
</details>
|
|
1074
1219
|
|
|
1220
|
+
<details><summary> Get Feature Descriptions </summary>
|
|
1221
|
+
|
|
1222
|
+
#### Description
|
|
1223
|
+
Look up each feature's source feature view and description from the feature store's registered feature views (column comments set via `FeatureView.attach_feature_desc(...)`). Useful for documenting a model's fields or auditing everything registered in the store.
|
|
1224
|
+
|
|
1225
|
+
`gitlabds.get_feature_descriptions(session, feature_store, features=None):`
|
|
1226
|
+
|
|
1227
|
+
#### Parameters:
|
|
1228
|
+
- **_session_** : Active Snowpark session (e.g., `fs_engine._session`)
|
|
1229
|
+
- **_feature_store_** : Initialized FeatureStore instance (e.g., `fs_engine`); used to enumerate the registered feature views and locate their db/schema
|
|
1230
|
+
- **_features_** : Feature names to look up. If None, returns every feature in the store's registered feature views.
|
|
1231
|
+
|
|
1232
|
+
#### Returns
|
|
1233
|
+
- DataFrame with columns: `feature`, `feature_view`, `data_type`, `description`. Features found in more than one feature view have their views/descriptions joined (`" | "` / `" || "`) so nothing is dropped. When `features` is provided, unmatched features (e.g., transformed or dummy-coded features) come back with blank `feature_view` / `data_type` / `description` for the user to populate manually, and the caller's order/list is preserved.
|
|
1234
|
+
|
|
1235
|
+
#### Requirements
|
|
1236
|
+
This function requires Snowflake packages. Install with:
|
|
1237
|
+
```bash
|
|
1238
|
+
pip install snowflake-snowpark-python snowflake-ml-python
|
|
1239
|
+
```
|
|
1240
|
+
|
|
1241
|
+
#### Examples:
|
|
1242
|
+
|
|
1243
|
+
```python
|
|
1244
|
+
import gitlabds
|
|
1245
|
+
|
|
1246
|
+
# Look up a specific list (e.g. the model fields from a config)
|
|
1247
|
+
desc = gitlabds.get_feature_descriptions(
|
|
1248
|
+
session=fs_engine._session,
|
|
1249
|
+
feature_store=fs_engine,
|
|
1250
|
+
features=scoring_config["model"]["fields"],
|
|
1251
|
+
)
|
|
1252
|
+
|
|
1253
|
+
# Or pull every feature registered in the store
|
|
1254
|
+
all_desc = gitlabds.get_feature_descriptions(session=fs_engine._session, feature_store=fs_engine)
|
|
1255
|
+
```
|
|
1256
|
+
|
|
1257
|
+
</details>
|
|
1258
|
+
|
|
1075
1259
|
### SQL and Trend Analysis
|
|
1076
1260
|
|
|
1077
1261
|
<details><summary> SQL Trend Query Generator </summary>
|
|
@@ -6,6 +6,7 @@ gitlabds/__init__.py
|
|
|
6
6
|
gitlabds/baselines.py
|
|
7
7
|
gitlabds/config_generator.py
|
|
8
8
|
gitlabds/dummy.py
|
|
9
|
+
gitlabds/feature_descriptions.py
|
|
9
10
|
gitlabds/feature_reduction.py
|
|
10
11
|
gitlabds/insights.py
|
|
11
12
|
gitlabds/memory_optimization.py
|
|
@@ -13,6 +14,7 @@ gitlabds/missing.py
|
|
|
13
14
|
gitlabds/model_evaluator.py
|
|
14
15
|
gitlabds/monitoring_metrics.py
|
|
15
16
|
gitlabds/outliers.py
|
|
17
|
+
gitlabds/record_benchmarking.py
|
|
16
18
|
gitlabds/serving_features.py
|
|
17
19
|
gitlabds/split_data.py
|
|
18
20
|
gitlabds/trends.py
|
|
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
|