gitlabds 2.1.11__tar.gz → 2.2.0__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.1.11 → gitlabds-2.2.0}/PKG-INFO +186 -2
  2. {gitlabds-2.1.11 → gitlabds-2.2.0}/README.md +185 -1
  3. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/__init__.py +4 -1
  4. gitlabds-2.2.0/gitlabds/feature_descriptions.py +106 -0
  5. gitlabds-2.2.0/gitlabds/record_benchmarking.py +360 -0
  6. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds.egg-info/PKG-INFO +186 -2
  7. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds.egg-info/SOURCES.txt +2 -0
  8. {gitlabds-2.1.11 → gitlabds-2.2.0}/LICENSE +0 -0
  9. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/baselines.py +0 -0
  10. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/config_generator.py +0 -0
  11. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/dummy.py +0 -0
  12. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/feature_reduction.py +0 -0
  13. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/insights.py +0 -0
  14. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/memory_optimization.py +0 -0
  15. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/missing.py +0 -0
  16. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/model_evaluator.py +0 -0
  17. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/monitoring_metrics.py +0 -0
  18. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/outliers.py +0 -0
  19. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/serving_features.py +0 -0
  20. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/split_data.py +0 -0
  21. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds/trends.py +0 -0
  22. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds.egg-info/dependency_links.txt +0 -0
  23. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds.egg-info/requires.txt +0 -0
  24. {gitlabds-2.1.11 → gitlabds-2.2.0}/gitlabds.egg-info/top_level.txt +0 -0
  25. {gitlabds-2.1.11 → gitlabds-2.2.0}/pyproject.toml +0 -0
  26. {gitlabds-2.1.11 → gitlabds-2.2.0}/setup.cfg +0 -0
  27. {gitlabds-2.1.11 → gitlabds-2.2.0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitlabds
3
- Version: 2.1.11
3
+ Version: 2.2.0
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 `clear_feature_serving_locks` functions require additional Snowflake dependencies.
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 status labels, one per input value
840
+
841
+ #### Examples:
842
+ ```python
843
+ import gitlabds
844
+
845
+ status = 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`, `band_before`, `band_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 `clear_feature_serving_locks` functions require additional Snowflake dependencies.
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 status labels, one per input value
808
+
809
+ #### Examples:
810
+ ```python
811
+ import gitlabds
812
+
813
+ status = 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`, `band_before`, `band_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 : status 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 status 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
+ status = 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
+ status = status.mask((eff_pct >= ceiling_pct) & (status == labels[2]), ceiling_label)
70
+ status = status.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
+ status = status.where(value != no_activity_value, labels[3])
74
+ return status
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, band_before, band_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["band_before"] = _band(out["p_before"])
293
+ out["band_after"] = _band(out["p_after"])
294
+ out["crosses_band"] = out["band_before"] != out["band_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.11
3
+ Version: 2.2.0
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 `clear_feature_serving_locks` functions require additional Snowflake dependencies.
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 status labels, one per input value
840
+
841
+ #### Examples:
842
+ ```python
843
+ import gitlabds
844
+
845
+ status = 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`, `band_before`, `band_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