shap_relativities
SHAP Relativities - actuarial-grade multiplicative relativities from tree models.
Converts a trained GBM's SHAP values into the same format as GLM exp(beta) relativities: a table of (feature, level, relativity) triples where the base level is 1.0 and relativities multiply together to give the model's expected prediction.
Typical usage
>>> from shap_relativities import SHAPRelativities
>>> sr = SHAPRelativities(model, X, exposure=df["exposure"],
... categorical_features=["area", "ncd_years"])
>>> sr.fit()
>>> rels = sr.extract_relativities(
... normalise_to="base_level",
... base_levels={"area": "A", "ncd_years": 0},
... )
Or use the convenience wrapper for one-liners:
>>> from shap_relativities import extract_relativities
>>> rels = extract_relativities(model, X, exposure=df["exposure"],
... categorical_features=["area"])
1""" 2SHAP Relativities - actuarial-grade multiplicative relativities from tree models. 3 4Converts a trained GBM's SHAP values into the same format as GLM exp(beta) 5relativities: a table of (feature, level, relativity) triples where the base 6level is 1.0 and relativities multiply together to give the model's expected 7prediction. 8 9Typical usage 10------------- 11>>> from shap_relativities import SHAPRelativities 12>>> sr = SHAPRelativities(model, X, exposure=df["exposure"], 13... categorical_features=["area", "ncd_years"]) 14>>> sr.fit() 15>>> rels = sr.extract_relativities( 16... normalise_to="base_level", 17... base_levels={"area": "A", "ncd_years": 0}, 18... ) 19 20Or use the convenience wrapper for one-liners: 21 22>>> from shap_relativities import extract_relativities 23>>> rels = extract_relativities(model, X, exposure=df["exposure"], 24... categorical_features=["area"]) 25""" 26 27from __future__ import annotations 28 29from typing import Any 30 31import polars as pl 32 33from ._core import SHAPRelativities 34 35__all__ = ["SHAPRelativities", "extract_relativities"] 36 37from importlib.metadata import version, PackageNotFoundError 38 39try: 40 __version__ = version("shap-relativities") 41except PackageNotFoundError: 42 __version__ = "0.0.0" # not installed 43 44 45def extract_relativities( 46 model: Any, 47 X: Any, 48 exposure: Any = None, 49 categorical_features: list[str] | None = None, 50 base_levels: dict[str, str | float | int] | None = None, 51 ci_method: str = "clt", 52) -> pl.DataFrame: 53 """ 54 One-shot extraction of SHAP relativities from a tree model. 55 56 Wraps SHAPRelativities.fit() and extract_relativities() for cases where 57 you don't need the intermediate object. 58 59 Args: 60 model: Trained CatBoost model with a log-link objective (Poisson, 61 Tweedie, or Gamma). CatBoost is the recommended choice - it handles 62 categorical features natively without encoding. 63 X: Feature matrix. Accepts a Polars or pandas DataFrame. Polars is 64 preferred; pandas is accepted and converted internally. 65 exposure: Earned policy years. If None, all observations are equally 66 weighted. 67 categorical_features: Features to aggregate by level. If None, all 68 non-numeric columns are treated as categorical. 69 base_levels: Base level for each categorical feature (gets 70 relativity = 1.0). 71 ci_method: "clt" (default) or "none". 72 73 Returns: 74 Polars DataFrame with columns: feature, level, relativity, lower_ci, 75 upper_ci, mean_shap, shap_std, n_obs, exposure_weight. 76 """ 77 sr = SHAPRelativities(model, X, exposure, categorical_features) 78 sr.fit() 79 return sr.extract_relativities(base_levels=base_levels, ci_method=ci_method)
87class SHAPRelativities: 88 """ 89 Extract multiplicative rating relativities from a tree model via SHAP. 90 91 Workflow:: 92 93 sr = SHAPRelativities( 94 model=catboost_model, 95 X=df.select(["area", "ncd_years", "has_convictions"]), 96 exposure=df["exposure"], 97 categorical_features=["area", "ncd_years"], 98 ) 99 sr.fit() 100 rels = sr.extract_relativities( 101 normalise_to="base_level", 102 base_levels={"area": "A", "ncd_years": 0}, 103 ) 104 105 Args: 106 model: A trained CatBoost model. Must use a log-link objective (Poisson, 107 Tweedie, Gamma). CatBoost is the recommended default - it handles 108 categoricals natively. 109 X: Feature matrix. Use training data for in-sample relativities, or a 110 representative holdout sample for out-of-sample. Polars DataFrames 111 are preferred; pandas DataFrames are accepted and converted 112 internally. 113 exposure: Earned policy years (or other volume measure). Used as 114 observation weights throughout. If None, all observations are 115 weighted equally. 116 categorical_features: Features to aggregate by level (bar-chart style). 117 If None, all non-numeric columns are treated as categorical. 118 continuous_features: Features to leave as per-observation points. 119 If None, all numeric columns are treated as continuous. 120 feature_perturbation: "tree_path_dependent" (default, fast, no 121 background data needed) or "interventional" (corrects for feature 122 correlation, needs background_data). 123 background_data: Required only if feature_perturbation="interventional". 124 n_background_samples: Number of background samples for interventional 125 SHAP. Default 1000. 126 annualise_exposure: If True and exposure is provided, subtract mean 127 log(exposure) from the expected_value to give an annualised 128 baseline. Default True. 129 """ 130 131 def __init__( 132 self, 133 model: Any, 134 X: Any, 135 exposure: Any = None, 136 categorical_features: list[str] | None = None, 137 continuous_features: list[str] | None = None, 138 background_data: Any = None, 139 feature_perturbation: str = "tree_path_dependent", 140 n_background_samples: int = 1000, 141 annualise_exposure: bool = True, 142 ) -> None: 143 if not _SHAP_AVAILABLE: 144 raise ImportError( 145 "shap is required for SHAPRelativities. " 146 "Install it with: uv add 'shap-relativities[ml]'" 147 ) 148 149 self._model = model 150 self._X: pl.DataFrame = _to_polars(X) 151 self._background_data = ( 152 _to_polars(background_data) if background_data is not None else None 153 ) 154 155 # Normalise exposure to a numpy array 156 if exposure is None: 157 self._exposure: np.ndarray | None = None 158 elif isinstance(exposure, np.ndarray): 159 self._exposure = exposure 160 elif isinstance(exposure, pl.Series): 161 self._exposure = exposure.to_numpy() 162 else: 163 # pd.Series or similar 164 self._exposure = np.asarray(exposure) 165 166 # Validate exposure length matches X 167 if self._exposure is not None and len(self._exposure) != len(self._X): 168 raise ValueError( 169 f"exposure length ({len(self._exposure)}) does not match " 170 f"X length ({len(self._X)}). Both must have the same number of rows." 171 ) 172 173 self._feature_perturbation = feature_perturbation 174 self._n_background_samples = n_background_samples 175 self._annualise_exposure = annualise_exposure 176 177 # Classify features 178 self._categorical_features = categorical_features or self._infer_categorical() 179 self._continuous_features = continuous_features or self._infer_continuous() 180 181 # Populated by fit() 182 self._shap_values: np.ndarray | None = None 183 self._expected_value: float | None = None 184 self._is_fitted: bool = False 185 186 def _infer_categorical(self) -> list[str]: 187 numeric_types = ( 188 pl.Int8, pl.Int16, pl.Int32, pl.Int64, 189 pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64, 190 pl.Float32, pl.Float64, 191 ) 192 return [ 193 c for c in self._X.columns 194 if not isinstance(self._X[c].dtype, numeric_types) 195 ] 196 197 def _infer_continuous(self) -> list[str]: 198 numeric_types = ( 199 pl.Int8, pl.Int16, pl.Int32, pl.Int64, 200 pl.UInt8, pl.UInt16, pl.UInt32, pl.UInt64, 201 pl.Float32, pl.Float64, 202 ) 203 return [ 204 c for c in self._X.columns 205 if isinstance(self._X[c].dtype, numeric_types) 206 and c not in (self._categorical_features or []) 207 ] 208 209 def fit(self) -> "SHAPRelativities": 210 """ 211 Compute SHAP values for all features in X. 212 213 Must be called before extract_relativities(). Calling fit() again 214 recomputes SHAP values (e.g. after changing X or the background data). 215 216 The feature matrix is converted to pandas internally for shap's 217 TreeExplainer. The conversion is a necessary bridge - shap requires 218 pandas for column name handling. 219 220 Returns: 221 Self, for method chaining. 222 """ 223 # Convert to pandas for shap - unavoidable bridge 224 X_pd = _to_pandas(self._X) 225 226 bg_data = None 227 if self._feature_perturbation == "interventional": 228 if self._background_data is not None: 229 bg_data = _to_pandas(self._background_data) 230 else: 231 n_bg = min(self._n_background_samples, len(X_pd)) 232 bg_data = shap.sample(X_pd, n_bg) 233 234 explainer = shap.TreeExplainer( 235 self._model, 236 data=bg_data, 237 feature_perturbation=self._feature_perturbation, 238 model_output="raw", 239 ) 240 241 raw = explainer.shap_values(X_pd) 242 243 # Some models return a list when there is a single output 244 if isinstance(raw, list): 245 if len(raw) == 1: 246 raw = raw[0] 247 else: 248 raise ValueError( 249 f"Model has {len(raw)} outputs. SHAPRelativities supports " 250 "single-output models only." 251 ) 252 253 self._shap_values = raw 254 255 ev = explainer.expected_value 256 if isinstance(ev, (list, np.ndarray)): 257 ev = float(ev[0]) 258 self._expected_value = float(ev) 259 260 self._is_fitted = True 261 return self 262 263 def _check_fitted(self) -> None: 264 if not self._is_fitted: 265 raise RuntimeError("Call fit() before using this method.") 266 267 def shap_values(self) -> np.ndarray: 268 """ 269 Raw SHAP values, shape (n_obs, n_features), in log space. 270 271 Returns: 272 Array of shape (n_obs, n_features). 273 """ 274 self._check_fitted() 275 return self._shap_values # type: ignore[return-value] 276 277 def baseline(self) -> float: 278 """ 279 exp(expected_value) - the base rate in prediction space. 280 281 If annualise_exposure=True and exposure was provided, this is adjusted 282 for the average log-exposure offset so it represents an annualised rate. 283 284 Returns: 285 Base rate as a float. 286 """ 287 self._check_fitted() 288 ev = self._expected_value # type: ignore[assignment] 289 290 if self._annualise_exposure and self._exposure is not None: 291 mean_log_exp = float(np.mean(np.log(np.clip(self._exposure, 1e-9, None)))) 292 ev = ev - mean_log_exp 293 294 return float(np.exp(ev)) 295 296 def extract_relativities( 297 self, 298 normalise_to: str = "base_level", 299 base_levels: dict[str, str | float | int] | None = None, 300 ci_method: str = "clt", 301 n_bootstrap: int = 200, 302 ci_level: float = 0.95, 303 ) -> pl.DataFrame: 304 """ 305 Extract multiplicative relativities from SHAP values. 306 307 Args: 308 normalise_to: "base_level" (base level for each feature gets 309 relativity = 1.0) or "mean" (exposure-weighted portfolio 310 mean = 1.0). 311 base_levels: Mapping of feature -> base level value. Required for 312 categorical features when normalise_to="base_level". Continuous 313 features automatically use mean normalisation regardless of 314 this setting. 315 ci_method: "clt" (CLT approximation, default, fast) or "none" (no 316 CIs). "bootstrap" is not yet implemented. 317 n_bootstrap: Ignored unless ci_method="bootstrap". 318 ci_level: Two-sided confidence level. Default 0.95. 319 320 Returns: 321 Polars DataFrame with columns: feature, level, relativity, 322 lower_ci, upper_ci, mean_shap, shap_std, n_obs, exposure_weight. 323 One row per (feature, level) combination. 324 """ 325 self._check_fitted() 326 327 _VALID_CI_METHODS = {"clt", "bootstrap", "none"} 328 if ci_method not in _VALID_CI_METHODS: 329 raise ValueError( 330 f"Unknown ci_method {ci_method!r}. " 331 f"Valid options are: {sorted(_VALID_CI_METHODS)}." 332 ) 333 334 if ci_method == "bootstrap": 335 raise NotImplementedError( 336 "Bootstrap CIs are not yet implemented. Use ci_method='clt'." 337 ) 338 339 base_levels = base_levels or {} 340 weights = ( 341 self._exposure if self._exposure is not None 342 else np.ones(len(self._X)) 343 ) 344 345 feature_names = self._X.columns 346 shap_vals = self._shap_values # type: ignore[assignment] 347 348 parts: list[pl.DataFrame] = [] 349 350 for i, feat in enumerate(feature_names): 351 feat_vals = self._X[feat].to_numpy() 352 shap_col = shap_vals[:, i] 353 354 is_categorical = feat in self._categorical_features 355 356 if is_categorical: 357 agg = aggregate_categorical(feat, feat_vals, shap_col, weights) 358 else: 359 agg = aggregate_continuous(feat, feat_vals, shap_col, weights) 360 361 # Normalisation 362 if normalise_to == "base_level" and is_categorical: 363 base = base_levels.get(feat) 364 if base is None: 365 # Fall back to the level with the smallest mean_shap as 366 # a sensible default (closest to intercept) 367 base = agg.sort("mean_shap")["level"][0] 368 warnings.warn( 369 f"No base level specified for '{feat}'. " 370 f"Using '{base}' (lowest mean SHAP) as base.", 371 UserWarning, 372 stacklevel=2, 373 ) 374 375 if ci_method == "none": 376 base_key = str(base) 377 base_rows = agg.filter(pl.col("level") == base_key) 378 base_shap = base_rows["mean_shap"][0] 379 agg = agg.with_columns([ 380 (pl.col("mean_shap") - base_shap).exp().alias("relativity"), 381 pl.lit(float("nan")).alias("lower_ci"), 382 pl.lit(float("nan")).alias("upper_ci"), 383 ]) 384 else: 385 agg = normalise_base_level(agg, base, ci_level=ci_level) 386 387 else: 388 # Mean normalisation for continuous features, or when 389 # normalise_to="mean" for any feature 390 if ci_method == "none": 391 total_weight = agg["exposure_weight"].sum() 392 portfolio_mean = float( 393 (agg["mean_shap"] * agg["exposure_weight"]).sum() 394 / total_weight 395 ) if total_weight > 0 else 0.0 396 agg = agg.with_columns([ 397 (pl.col("mean_shap") - portfolio_mean).exp().alias("relativity"), 398 pl.lit(float("nan")).alias("lower_ci"), 399 pl.lit(float("nan")).alias("upper_ci"), 400 ]) 401 else: 402 agg = normalise_mean(agg, ci_level=ci_level) 403 404 parts.append(agg) 405 406 # Cast the 'level' column to Utf8 in every part before concat. 407 # Categorical features produce level as Utf8; continuous features 408 # produce level as Float64. pl.concat with how="diagonal" cannot 409 # unify mismatched types for the same column name, so we normalise 410 # here rather than requiring callers to pre-cast their feature columns. 411 parts = [ 412 p.with_columns(pl.col("level").cast(pl.Utf8)) 413 if "level" in p.columns else p 414 for p in parts 415 ] 416 417 result = pl.concat(parts, how="diagonal") 418 419 # Ensure standard column order (wsq_weight is internal, not exported) 420 available = [c for c in _RELATIVITY_COLUMNS if c in result.columns] 421 return result.select(available) 422 423 def extract_continuous_curve( 424 self, 425 feature: str, 426 n_points: int = 100, 427 smooth_method: str = "loess", 428 ) -> pl.DataFrame: 429 """ 430 Smoothed relativity curve for a continuous feature. 431 432 Args: 433 feature: Feature name. Must be in continuous_features. 434 n_points: Number of points in the output curve (not the input 435 data). 436 smooth_method: "loess" (locally weighted regression, requires 437 statsmodels), "isotonic" (monotone curve via isotonic 438 regression), or "none" (raw per-observation relativities). 439 440 Returns: 441 Polars DataFrame with columns: feature_value, relativity, 442 lower_ci, upper_ci. 443 444 Raises: 445 ValueError: If feature is not in X or smooth_method is unknown. 446 """ 447 self._check_fitted() 448 449 if feature not in self._X.columns: 450 raise ValueError(f"Feature '{feature}' not in X.") 451 452 feat_idx = self._X.columns.index(feature) 453 feat_vals = self._X[feature].to_numpy().astype(float) 454 shap_col = self._shap_values[:, feat_idx] # type: ignore[index] 455 weights = ( 456 self._exposure if self._exposure is not None 457 else np.ones(len(self._X)) 458 ) 459 460 # Exposure-weighted mean over the actual data distribution 461 portfolio_mean = np.average(shap_col, weights=weights) 462 relativities = np.exp(shap_col - portfolio_mean) 463 464 grid = np.linspace(feat_vals.min(), feat_vals.max(), n_points) 465 466 if smooth_method == "none": 467 order = np.argsort(feat_vals) 468 return pl.DataFrame({ 469 "feature_value": feat_vals[order], 470 "relativity": relativities[order], 471 "lower_ci": np.full(len(feat_vals), float("nan")), 472 "upper_ci": np.full(len(feat_vals), float("nan")), 473 }) 474 475 elif smooth_method == "isotonic": 476 from sklearn.isotonic import IsotonicRegression 477 ir = IsotonicRegression(out_of_bounds="clip") 478 ir.fit(feat_vals, shap_col, sample_weight=weights) 479 smoothed_shap = ir.predict(grid) 480 481 # P1-4 fix: normalise the smoothed curve so the exposure-weighted 482 # geometric mean of relativities = 1.0. 483 # The smooth is on the data, evaluated on a uniform grid. Subtracting 484 # portfolio_mean (computed on the data distribution) would be correct 485 # only if the grid were distributed like the data — it isn't. 486 # Instead, compute the data-distribution-weighted mean of the smoothed 487 # curve at the original data points, then use that as the reference. 488 smoothed_at_data = ir.predict(feat_vals) 489 weighted_mean_smoothed = np.average(smoothed_at_data, weights=weights) 490 smoothed_rel = np.exp(smoothed_shap - weighted_mean_smoothed) 491 492 return pl.DataFrame({ 493 "feature_value": grid, 494 "relativity": smoothed_rel, 495 "lower_ci": np.full(n_points, float("nan")), 496 "upper_ci": np.full(n_points, float("nan")), 497 }) 498 499 elif smooth_method == "loess": 500 try: 501 from statsmodels.nonparametric.smoothers_lowess import lowess 502 503 smoothed_shap = lowess( 504 shap_col, feat_vals, frac=0.3, it=3, 505 xvals=grid, is_sorted=False, 506 ) 507 508 # P1-4 fix: compute the smoothed values at original data points 509 # so the normalisation is data-distribution-weighted, not 510 # grid-uniform. Use the same lowess parameters. 511 smoothed_at_data = lowess( 512 shap_col, feat_vals, frac=0.3, it=3, 513 xvals=feat_vals, is_sorted=False, 514 ) 515 weighted_mean_smoothed = np.average(smoothed_at_data, weights=weights) 516 smoothed_rel = np.exp(smoothed_shap - weighted_mean_smoothed) 517 518 return pl.DataFrame({ 519 "feature_value": grid, 520 "relativity": smoothed_rel, 521 "lower_ci": np.full(n_points, float("nan")), 522 "upper_ci": np.full(n_points, float("nan")), 523 }) 524 except ImportError: 525 warnings.warn( 526 "statsmodels not installed; falling back to smooth_method='none'.", 527 UserWarning, 528 stacklevel=2, 529 ) 530 return self.extract_continuous_curve( 531 feature, n_points=n_points, smooth_method="none" 532 ) 533 534 else: 535 raise ValueError( 536 f"Unknown smooth_method '{smooth_method}'. " 537 "Choose from: 'loess', 'isotonic', 'none'." 538 ) 539 540 def validate(self) -> dict[str, CheckResult]: 541 """ 542 Run diagnostic checks on the SHAP computation. 543 544 Checks performed: 545 546 1. reconstruction: exp(shap.sum(1) + expected_value) should match 547 model predictions within tolerance. Material failure here indicates 548 the explainer was set up incorrectly. 549 550 2. feature_coverage: every feature in X should appear in the SHAP 551 output. Currently always passes given TreeExplainer's API. 552 553 3. sparse_levels: warns if any categorical level has fewer than 30 554 observations. CLT CIs will be unreliable for these levels. 555 556 Returns: 557 Dict with keys "reconstruction", "feature_coverage", 558 "sparse_levels". Each value is a CheckResult(passed, value, 559 message). 560 """ 561 self._check_fitted() 562 563 X_pd = _to_pandas(self._X) 564 565 # Get model predictions for reconstruction check 566 preds = None 567 if self._model is not None: 568 try: 569 preds = self._model.predict(X_pd) 570 except Exception: 571 preds = None 572 573 results: dict[str, CheckResult] = {} 574 575 if preds is not None: 576 results["reconstruction"] = check_reconstruction( 577 self._shap_values, # type: ignore[arg-type] 578 self._expected_value, # type: ignore[arg-type] 579 preds, 580 tolerance=1e-4, 581 ) 582 else: 583 results["reconstruction"] = CheckResult( 584 passed=False, 585 value=float("nan"), 586 message="Could not obtain model predictions for reconstruction check.", 587 ) 588 589 feature_names = self._X.columns 590 results["feature_coverage"] = check_feature_coverage( 591 feature_names, feature_names 592 ) 593 594 # Check sparse levels for categorical features 595 weights = ( 596 self._exposure if self._exposure is not None 597 else np.ones(len(self._X)) 598 ) 599 600 sparse_parts: list[pl.DataFrame] = [] 601 for feat in self._categorical_features: 602 if feat not in self._X.columns: 603 continue 604 feat_idx = self._X.columns.index(feat) 605 agg = aggregate_categorical( 606 feat, 607 self._X[feat].to_numpy(), 608 self._shap_values[:, feat_idx], # type: ignore[index] 609 weights, 610 ) 611 sparse_parts.append(agg) 612 613 if sparse_parts: 614 all_agg = pl.concat(sparse_parts, how="diagonal") 615 results["sparse_levels"] = check_sparse_levels(all_agg) 616 else: 617 results["sparse_levels"] = CheckResult( 618 passed=True, value=0.0, 619 message="No categorical features to check." 620 ) 621 622 return results 623 624 def plot_relativities( 625 self, 626 features: list[str] | None = None, 627 show_ci: bool = True, 628 figsize: tuple[int, int] = (12, 8), 629 ) -> None: 630 """ 631 Plot relativities as bar charts (categorical) or line charts (continuous). 632 633 Args: 634 features: Subset of features to plot. Defaults to all features. 635 show_ci: Whether to show confidence intervals. Default True. 636 figsize: Overall figure size. 637 """ 638 self._check_fitted() 639 640 from ._plotting import plot_relativities as _plot 641 642 rels = self.extract_relativities() 643 _plot( 644 rels, 645 categorical_features=self._categorical_features, 646 continuous_features=self._continuous_features, 647 features=features, 648 show_ci=show_ci, 649 figsize=figsize, 650 ) 651 652 def to_dict(self) -> dict[str, Any]: 653 """ 654 Serialisable representation of the fitted object. 655 656 Stores SHAP values, expected value, feature names, and feature 657 classification. Does not store the original model or X DataFrame. 658 659 Returns: 660 Dict suitable for JSON serialisation. 661 """ 662 self._check_fitted() 663 return { 664 "shap_values": self._shap_values.tolist(), # type: ignore[union-attr] 665 "expected_value": self._expected_value, 666 "feature_names": self._X.columns, 667 "categorical_features": self._categorical_features, 668 "continuous_features": self._continuous_features, 669 "X_values": {c: self._X[c].to_list() for c in self._X.columns}, 670 "exposure": ( 671 self._exposure.tolist() 672 if self._exposure is not None else None 673 ), 674 "annualise_exposure": self._annualise_exposure, 675 } 676 677 @classmethod 678 def from_dict(cls, data: dict[str, Any]) -> "SHAPRelativities": 679 """ 680 Reconstruct a fitted SHAPRelativities from to_dict() output. 681 682 The reconstructed object has no model attached, so validate() and 683 plot_relativities() still work but fit() cannot be re-run. 684 685 Args: 686 data: Output of to_dict(). 687 688 Returns: 689 Fitted SHAPRelativities instance. 690 """ 691 # P1-2 fix: use feature_names to control column ordering in X. 692 # Without this, tools that sort JSON object keys (REST APIs, some 693 # pretty-printers) reorder X_values, misaligning columns with the 694 # shap_values matrix columns. 695 feature_names: list[str] = data.get("feature_names", list(data["X_values"].keys())) 696 X = pl.DataFrame({k: data["X_values"][k] for k in feature_names}) 697 698 exposure = ( 699 np.array(data["exposure"]) if data.get("exposure") is not None 700 else None 701 ) 702 703 # Create a minimal instance without a real model 704 instance = cls.__new__(cls) 705 instance._model = None 706 instance._X = X 707 instance._exposure = exposure 708 instance._categorical_features = data.get("categorical_features", []) 709 instance._continuous_features = data.get("continuous_features", []) 710 instance._feature_perturbation = "tree_path_dependent" 711 instance._background_data = None 712 instance._n_background_samples = 1000 713 instance._annualise_exposure = data.get("annualise_exposure", True) 714 instance._shap_values = np.array(data["shap_values"]) 715 instance._expected_value = float(data["expected_value"]) 716 instance._is_fitted = True 717 718 return instance
Extract multiplicative rating relativities from a tree model via SHAP.
Workflow::
sr = SHAPRelativities(
model=catboost_model,
X=df.select(["area", "ncd_years", "has_convictions"]),
exposure=df["exposure"],
categorical_features=["area", "ncd_years"],
)
sr.fit()
rels = sr.extract_relativities(
normalise_to="base_level",
base_levels={"area": "A", "ncd_years": 0},
)
Arguments:
- model: A trained CatBoost model. Must use a log-link objective (Poisson, Tweedie, Gamma). CatBoost is the recommended default - it handles categoricals natively.
- X: Feature matrix. Use training data for in-sample relativities, or a representative holdout sample for out-of-sample. Polars DataFrames are preferred; pandas DataFrames are accepted and converted internally.
- exposure: Earned policy years (or other volume measure). Used as observation weights throughout. If None, all observations are weighted equally.
- categorical_features: Features to aggregate by level (bar-chart style). If None, all non-numeric columns are treated as categorical.
- continuous_features: Features to leave as per-observation points. If None, all numeric columns are treated as continuous.
- feature_perturbation: "tree_path_dependent" (default, fast, no background data needed) or "interventional" (corrects for feature correlation, needs background_data).
- background_data: Required only if feature_perturbation="interventional".
- n_background_samples: Number of background samples for interventional SHAP. Default 1000.
- annualise_exposure: If True and exposure is provided, subtract mean log(exposure) from the expected_value to give an annualised baseline. Default True.
131 def __init__( 132 self, 133 model: Any, 134 X: Any, 135 exposure: Any = None, 136 categorical_features: list[str] | None = None, 137 continuous_features: list[str] | None = None, 138 background_data: Any = None, 139 feature_perturbation: str = "tree_path_dependent", 140 n_background_samples: int = 1000, 141 annualise_exposure: bool = True, 142 ) -> None: 143 if not _SHAP_AVAILABLE: 144 raise ImportError( 145 "shap is required for SHAPRelativities. " 146 "Install it with: uv add 'shap-relativities[ml]'" 147 ) 148 149 self._model = model 150 self._X: pl.DataFrame = _to_polars(X) 151 self._background_data = ( 152 _to_polars(background_data) if background_data is not None else None 153 ) 154 155 # Normalise exposure to a numpy array 156 if exposure is None: 157 self._exposure: np.ndarray | None = None 158 elif isinstance(exposure, np.ndarray): 159 self._exposure = exposure 160 elif isinstance(exposure, pl.Series): 161 self._exposure = exposure.to_numpy() 162 else: 163 # pd.Series or similar 164 self._exposure = np.asarray(exposure) 165 166 # Validate exposure length matches X 167 if self._exposure is not None and len(self._exposure) != len(self._X): 168 raise ValueError( 169 f"exposure length ({len(self._exposure)}) does not match " 170 f"X length ({len(self._X)}). Both must have the same number of rows." 171 ) 172 173 self._feature_perturbation = feature_perturbation 174 self._n_background_samples = n_background_samples 175 self._annualise_exposure = annualise_exposure 176 177 # Classify features 178 self._categorical_features = categorical_features or self._infer_categorical() 179 self._continuous_features = continuous_features or self._infer_continuous() 180 181 # Populated by fit() 182 self._shap_values: np.ndarray | None = None 183 self._expected_value: float | None = None 184 self._is_fitted: bool = False
209 def fit(self) -> "SHAPRelativities": 210 """ 211 Compute SHAP values for all features in X. 212 213 Must be called before extract_relativities(). Calling fit() again 214 recomputes SHAP values (e.g. after changing X or the background data). 215 216 The feature matrix is converted to pandas internally for shap's 217 TreeExplainer. The conversion is a necessary bridge - shap requires 218 pandas for column name handling. 219 220 Returns: 221 Self, for method chaining. 222 """ 223 # Convert to pandas for shap - unavoidable bridge 224 X_pd = _to_pandas(self._X) 225 226 bg_data = None 227 if self._feature_perturbation == "interventional": 228 if self._background_data is not None: 229 bg_data = _to_pandas(self._background_data) 230 else: 231 n_bg = min(self._n_background_samples, len(X_pd)) 232 bg_data = shap.sample(X_pd, n_bg) 233 234 explainer = shap.TreeExplainer( 235 self._model, 236 data=bg_data, 237 feature_perturbation=self._feature_perturbation, 238 model_output="raw", 239 ) 240 241 raw = explainer.shap_values(X_pd) 242 243 # Some models return a list when there is a single output 244 if isinstance(raw, list): 245 if len(raw) == 1: 246 raw = raw[0] 247 else: 248 raise ValueError( 249 f"Model has {len(raw)} outputs. SHAPRelativities supports " 250 "single-output models only." 251 ) 252 253 self._shap_values = raw 254 255 ev = explainer.expected_value 256 if isinstance(ev, (list, np.ndarray)): 257 ev = float(ev[0]) 258 self._expected_value = float(ev) 259 260 self._is_fitted = True 261 return self
Compute SHAP values for all features in X.
Must be called before extract_relativities(). Calling fit() again recomputes SHAP values (e.g. after changing X or the background data).
The feature matrix is converted to pandas internally for shap's TreeExplainer. The conversion is a necessary bridge - shap requires pandas for column name handling.
Returns:
Self, for method chaining.
267 def shap_values(self) -> np.ndarray: 268 """ 269 Raw SHAP values, shape (n_obs, n_features), in log space. 270 271 Returns: 272 Array of shape (n_obs, n_features). 273 """ 274 self._check_fitted() 275 return self._shap_values # type: ignore[return-value]
Raw SHAP values, shape (n_obs, n_features), in log space.
Returns:
Array of shape (n_obs, n_features).
277 def baseline(self) -> float: 278 """ 279 exp(expected_value) - the base rate in prediction space. 280 281 If annualise_exposure=True and exposure was provided, this is adjusted 282 for the average log-exposure offset so it represents an annualised rate. 283 284 Returns: 285 Base rate as a float. 286 """ 287 self._check_fitted() 288 ev = self._expected_value # type: ignore[assignment] 289 290 if self._annualise_exposure and self._exposure is not None: 291 mean_log_exp = float(np.mean(np.log(np.clip(self._exposure, 1e-9, None)))) 292 ev = ev - mean_log_exp 293 294 return float(np.exp(ev))
exp(expected_value) - the base rate in prediction space.
If annualise_exposure=True and exposure was provided, this is adjusted for the average log-exposure offset so it represents an annualised rate.
Returns:
Base rate as a float.
296 def extract_relativities( 297 self, 298 normalise_to: str = "base_level", 299 base_levels: dict[str, str | float | int] | None = None, 300 ci_method: str = "clt", 301 n_bootstrap: int = 200, 302 ci_level: float = 0.95, 303 ) -> pl.DataFrame: 304 """ 305 Extract multiplicative relativities from SHAP values. 306 307 Args: 308 normalise_to: "base_level" (base level for each feature gets 309 relativity = 1.0) or "mean" (exposure-weighted portfolio 310 mean = 1.0). 311 base_levels: Mapping of feature -> base level value. Required for 312 categorical features when normalise_to="base_level". Continuous 313 features automatically use mean normalisation regardless of 314 this setting. 315 ci_method: "clt" (CLT approximation, default, fast) or "none" (no 316 CIs). "bootstrap" is not yet implemented. 317 n_bootstrap: Ignored unless ci_method="bootstrap". 318 ci_level: Two-sided confidence level. Default 0.95. 319 320 Returns: 321 Polars DataFrame with columns: feature, level, relativity, 322 lower_ci, upper_ci, mean_shap, shap_std, n_obs, exposure_weight. 323 One row per (feature, level) combination. 324 """ 325 self._check_fitted() 326 327 _VALID_CI_METHODS = {"clt", "bootstrap", "none"} 328 if ci_method not in _VALID_CI_METHODS: 329 raise ValueError( 330 f"Unknown ci_method {ci_method!r}. " 331 f"Valid options are: {sorted(_VALID_CI_METHODS)}." 332 ) 333 334 if ci_method == "bootstrap": 335 raise NotImplementedError( 336 "Bootstrap CIs are not yet implemented. Use ci_method='clt'." 337 ) 338 339 base_levels = base_levels or {} 340 weights = ( 341 self._exposure if self._exposure is not None 342 else np.ones(len(self._X)) 343 ) 344 345 feature_names = self._X.columns 346 shap_vals = self._shap_values # type: ignore[assignment] 347 348 parts: list[pl.DataFrame] = [] 349 350 for i, feat in enumerate(feature_names): 351 feat_vals = self._X[feat].to_numpy() 352 shap_col = shap_vals[:, i] 353 354 is_categorical = feat in self._categorical_features 355 356 if is_categorical: 357 agg = aggregate_categorical(feat, feat_vals, shap_col, weights) 358 else: 359 agg = aggregate_continuous(feat, feat_vals, shap_col, weights) 360 361 # Normalisation 362 if normalise_to == "base_level" and is_categorical: 363 base = base_levels.get(feat) 364 if base is None: 365 # Fall back to the level with the smallest mean_shap as 366 # a sensible default (closest to intercept) 367 base = agg.sort("mean_shap")["level"][0] 368 warnings.warn( 369 f"No base level specified for '{feat}'. " 370 f"Using '{base}' (lowest mean SHAP) as base.", 371 UserWarning, 372 stacklevel=2, 373 ) 374 375 if ci_method == "none": 376 base_key = str(base) 377 base_rows = agg.filter(pl.col("level") == base_key) 378 base_shap = base_rows["mean_shap"][0] 379 agg = agg.with_columns([ 380 (pl.col("mean_shap") - base_shap).exp().alias("relativity"), 381 pl.lit(float("nan")).alias("lower_ci"), 382 pl.lit(float("nan")).alias("upper_ci"), 383 ]) 384 else: 385 agg = normalise_base_level(agg, base, ci_level=ci_level) 386 387 else: 388 # Mean normalisation for continuous features, or when 389 # normalise_to="mean" for any feature 390 if ci_method == "none": 391 total_weight = agg["exposure_weight"].sum() 392 portfolio_mean = float( 393 (agg["mean_shap"] * agg["exposure_weight"]).sum() 394 / total_weight 395 ) if total_weight > 0 else 0.0 396 agg = agg.with_columns([ 397 (pl.col("mean_shap") - portfolio_mean).exp().alias("relativity"), 398 pl.lit(float("nan")).alias("lower_ci"), 399 pl.lit(float("nan")).alias("upper_ci"), 400 ]) 401 else: 402 agg = normalise_mean(agg, ci_level=ci_level) 403 404 parts.append(agg) 405 406 # Cast the 'level' column to Utf8 in every part before concat. 407 # Categorical features produce level as Utf8; continuous features 408 # produce level as Float64. pl.concat with how="diagonal" cannot 409 # unify mismatched types for the same column name, so we normalise 410 # here rather than requiring callers to pre-cast their feature columns. 411 parts = [ 412 p.with_columns(pl.col("level").cast(pl.Utf8)) 413 if "level" in p.columns else p 414 for p in parts 415 ] 416 417 result = pl.concat(parts, how="diagonal") 418 419 # Ensure standard column order (wsq_weight is internal, not exported) 420 available = [c for c in _RELATIVITY_COLUMNS if c in result.columns] 421 return result.select(available)
Extract multiplicative relativities from SHAP values.
Arguments:
- normalise_to: "base_level" (base level for each feature gets relativity = 1.0) or "mean" (exposure-weighted portfolio mean = 1.0).
- base_levels: Mapping of feature -> base level value. Required for categorical features when normalise_to="base_level". Continuous features automatically use mean normalisation regardless of this setting.
- ci_method: "clt" (CLT approximation, default, fast) or "none" (no CIs). "bootstrap" is not yet implemented.
- n_bootstrap: Ignored unless ci_method="bootstrap".
- ci_level: Two-sided confidence level. Default 0.95.
Returns:
Polars DataFrame with columns: feature, level, relativity, lower_ci, upper_ci, mean_shap, shap_std, n_obs, exposure_weight. One row per (feature, level) combination.
423 def extract_continuous_curve( 424 self, 425 feature: str, 426 n_points: int = 100, 427 smooth_method: str = "loess", 428 ) -> pl.DataFrame: 429 """ 430 Smoothed relativity curve for a continuous feature. 431 432 Args: 433 feature: Feature name. Must be in continuous_features. 434 n_points: Number of points in the output curve (not the input 435 data). 436 smooth_method: "loess" (locally weighted regression, requires 437 statsmodels), "isotonic" (monotone curve via isotonic 438 regression), or "none" (raw per-observation relativities). 439 440 Returns: 441 Polars DataFrame with columns: feature_value, relativity, 442 lower_ci, upper_ci. 443 444 Raises: 445 ValueError: If feature is not in X or smooth_method is unknown. 446 """ 447 self._check_fitted() 448 449 if feature not in self._X.columns: 450 raise ValueError(f"Feature '{feature}' not in X.") 451 452 feat_idx = self._X.columns.index(feature) 453 feat_vals = self._X[feature].to_numpy().astype(float) 454 shap_col = self._shap_values[:, feat_idx] # type: ignore[index] 455 weights = ( 456 self._exposure if self._exposure is not None 457 else np.ones(len(self._X)) 458 ) 459 460 # Exposure-weighted mean over the actual data distribution 461 portfolio_mean = np.average(shap_col, weights=weights) 462 relativities = np.exp(shap_col - portfolio_mean) 463 464 grid = np.linspace(feat_vals.min(), feat_vals.max(), n_points) 465 466 if smooth_method == "none": 467 order = np.argsort(feat_vals) 468 return pl.DataFrame({ 469 "feature_value": feat_vals[order], 470 "relativity": relativities[order], 471 "lower_ci": np.full(len(feat_vals), float("nan")), 472 "upper_ci": np.full(len(feat_vals), float("nan")), 473 }) 474 475 elif smooth_method == "isotonic": 476 from sklearn.isotonic import IsotonicRegression 477 ir = IsotonicRegression(out_of_bounds="clip") 478 ir.fit(feat_vals, shap_col, sample_weight=weights) 479 smoothed_shap = ir.predict(grid) 480 481 # P1-4 fix: normalise the smoothed curve so the exposure-weighted 482 # geometric mean of relativities = 1.0. 483 # The smooth is on the data, evaluated on a uniform grid. Subtracting 484 # portfolio_mean (computed on the data distribution) would be correct 485 # only if the grid were distributed like the data — it isn't. 486 # Instead, compute the data-distribution-weighted mean of the smoothed 487 # curve at the original data points, then use that as the reference. 488 smoothed_at_data = ir.predict(feat_vals) 489 weighted_mean_smoothed = np.average(smoothed_at_data, weights=weights) 490 smoothed_rel = np.exp(smoothed_shap - weighted_mean_smoothed) 491 492 return pl.DataFrame({ 493 "feature_value": grid, 494 "relativity": smoothed_rel, 495 "lower_ci": np.full(n_points, float("nan")), 496 "upper_ci": np.full(n_points, float("nan")), 497 }) 498 499 elif smooth_method == "loess": 500 try: 501 from statsmodels.nonparametric.smoothers_lowess import lowess 502 503 smoothed_shap = lowess( 504 shap_col, feat_vals, frac=0.3, it=3, 505 xvals=grid, is_sorted=False, 506 ) 507 508 # P1-4 fix: compute the smoothed values at original data points 509 # so the normalisation is data-distribution-weighted, not 510 # grid-uniform. Use the same lowess parameters. 511 smoothed_at_data = lowess( 512 shap_col, feat_vals, frac=0.3, it=3, 513 xvals=feat_vals, is_sorted=False, 514 ) 515 weighted_mean_smoothed = np.average(smoothed_at_data, weights=weights) 516 smoothed_rel = np.exp(smoothed_shap - weighted_mean_smoothed) 517 518 return pl.DataFrame({ 519 "feature_value": grid, 520 "relativity": smoothed_rel, 521 "lower_ci": np.full(n_points, float("nan")), 522 "upper_ci": np.full(n_points, float("nan")), 523 }) 524 except ImportError: 525 warnings.warn( 526 "statsmodels not installed; falling back to smooth_method='none'.", 527 UserWarning, 528 stacklevel=2, 529 ) 530 return self.extract_continuous_curve( 531 feature, n_points=n_points, smooth_method="none" 532 ) 533 534 else: 535 raise ValueError( 536 f"Unknown smooth_method '{smooth_method}'. " 537 "Choose from: 'loess', 'isotonic', 'none'." 538 )
Smoothed relativity curve for a continuous feature.
Arguments:
- feature: Feature name. Must be in continuous_features.
- n_points: Number of points in the output curve (not the input data).
- smooth_method: "loess" (locally weighted regression, requires statsmodels), "isotonic" (monotone curve via isotonic regression), or "none" (raw per-observation relativities).
Returns:
Polars DataFrame with columns: feature_value, relativity, lower_ci, upper_ci.
Raises:
- ValueError: If feature is not in X or smooth_method is unknown.
540 def validate(self) -> dict[str, CheckResult]: 541 """ 542 Run diagnostic checks on the SHAP computation. 543 544 Checks performed: 545 546 1. reconstruction: exp(shap.sum(1) + expected_value) should match 547 model predictions within tolerance. Material failure here indicates 548 the explainer was set up incorrectly. 549 550 2. feature_coverage: every feature in X should appear in the SHAP 551 output. Currently always passes given TreeExplainer's API. 552 553 3. sparse_levels: warns if any categorical level has fewer than 30 554 observations. CLT CIs will be unreliable for these levels. 555 556 Returns: 557 Dict with keys "reconstruction", "feature_coverage", 558 "sparse_levels". Each value is a CheckResult(passed, value, 559 message). 560 """ 561 self._check_fitted() 562 563 X_pd = _to_pandas(self._X) 564 565 # Get model predictions for reconstruction check 566 preds = None 567 if self._model is not None: 568 try: 569 preds = self._model.predict(X_pd) 570 except Exception: 571 preds = None 572 573 results: dict[str, CheckResult] = {} 574 575 if preds is not None: 576 results["reconstruction"] = check_reconstruction( 577 self._shap_values, # type: ignore[arg-type] 578 self._expected_value, # type: ignore[arg-type] 579 preds, 580 tolerance=1e-4, 581 ) 582 else: 583 results["reconstruction"] = CheckResult( 584 passed=False, 585 value=float("nan"), 586 message="Could not obtain model predictions for reconstruction check.", 587 ) 588 589 feature_names = self._X.columns 590 results["feature_coverage"] = check_feature_coverage( 591 feature_names, feature_names 592 ) 593 594 # Check sparse levels for categorical features 595 weights = ( 596 self._exposure if self._exposure is not None 597 else np.ones(len(self._X)) 598 ) 599 600 sparse_parts: list[pl.DataFrame] = [] 601 for feat in self._categorical_features: 602 if feat not in self._X.columns: 603 continue 604 feat_idx = self._X.columns.index(feat) 605 agg = aggregate_categorical( 606 feat, 607 self._X[feat].to_numpy(), 608 self._shap_values[:, feat_idx], # type: ignore[index] 609 weights, 610 ) 611 sparse_parts.append(agg) 612 613 if sparse_parts: 614 all_agg = pl.concat(sparse_parts, how="diagonal") 615 results["sparse_levels"] = check_sparse_levels(all_agg) 616 else: 617 results["sparse_levels"] = CheckResult( 618 passed=True, value=0.0, 619 message="No categorical features to check." 620 ) 621 622 return results
Run diagnostic checks on the SHAP computation.
Checks performed:
reconstruction: exp(shap.sum(1) + expected_value) should match model predictions within tolerance. Material failure here indicates the explainer was set up incorrectly.
feature_coverage: every feature in X should appear in the SHAP output. Currently always passes given TreeExplainer's API.
sparse_levels: warns if any categorical level has fewer than 30 observations. CLT CIs will be unreliable for these levels.
Returns:
Dict with keys "reconstruction", "feature_coverage", "sparse_levels". Each value is a CheckResult(passed, value, message).
624 def plot_relativities( 625 self, 626 features: list[str] | None = None, 627 show_ci: bool = True, 628 figsize: tuple[int, int] = (12, 8), 629 ) -> None: 630 """ 631 Plot relativities as bar charts (categorical) or line charts (continuous). 632 633 Args: 634 features: Subset of features to plot. Defaults to all features. 635 show_ci: Whether to show confidence intervals. Default True. 636 figsize: Overall figure size. 637 """ 638 self._check_fitted() 639 640 from ._plotting import plot_relativities as _plot 641 642 rels = self.extract_relativities() 643 _plot( 644 rels, 645 categorical_features=self._categorical_features, 646 continuous_features=self._continuous_features, 647 features=features, 648 show_ci=show_ci, 649 figsize=figsize, 650 )
Plot relativities as bar charts (categorical) or line charts (continuous).
Arguments:
- features: Subset of features to plot. Defaults to all features.
- show_ci: Whether to show confidence intervals. Default True.
- figsize: Overall figure size.
652 def to_dict(self) -> dict[str, Any]: 653 """ 654 Serialisable representation of the fitted object. 655 656 Stores SHAP values, expected value, feature names, and feature 657 classification. Does not store the original model or X DataFrame. 658 659 Returns: 660 Dict suitable for JSON serialisation. 661 """ 662 self._check_fitted() 663 return { 664 "shap_values": self._shap_values.tolist(), # type: ignore[union-attr] 665 "expected_value": self._expected_value, 666 "feature_names": self._X.columns, 667 "categorical_features": self._categorical_features, 668 "continuous_features": self._continuous_features, 669 "X_values": {c: self._X[c].to_list() for c in self._X.columns}, 670 "exposure": ( 671 self._exposure.tolist() 672 if self._exposure is not None else None 673 ), 674 "annualise_exposure": self._annualise_exposure, 675 }
Serialisable representation of the fitted object.
Stores SHAP values, expected value, feature names, and feature classification. Does not store the original model or X DataFrame.
Returns:
Dict suitable for JSON serialisation.
677 @classmethod 678 def from_dict(cls, data: dict[str, Any]) -> "SHAPRelativities": 679 """ 680 Reconstruct a fitted SHAPRelativities from to_dict() output. 681 682 The reconstructed object has no model attached, so validate() and 683 plot_relativities() still work but fit() cannot be re-run. 684 685 Args: 686 data: Output of to_dict(). 687 688 Returns: 689 Fitted SHAPRelativities instance. 690 """ 691 # P1-2 fix: use feature_names to control column ordering in X. 692 # Without this, tools that sort JSON object keys (REST APIs, some 693 # pretty-printers) reorder X_values, misaligning columns with the 694 # shap_values matrix columns. 695 feature_names: list[str] = data.get("feature_names", list(data["X_values"].keys())) 696 X = pl.DataFrame({k: data["X_values"][k] for k in feature_names}) 697 698 exposure = ( 699 np.array(data["exposure"]) if data.get("exposure") is not None 700 else None 701 ) 702 703 # Create a minimal instance without a real model 704 instance = cls.__new__(cls) 705 instance._model = None 706 instance._X = X 707 instance._exposure = exposure 708 instance._categorical_features = data.get("categorical_features", []) 709 instance._continuous_features = data.get("continuous_features", []) 710 instance._feature_perturbation = "tree_path_dependent" 711 instance._background_data = None 712 instance._n_background_samples = 1000 713 instance._annualise_exposure = data.get("annualise_exposure", True) 714 instance._shap_values = np.array(data["shap_values"]) 715 instance._expected_value = float(data["expected_value"]) 716 instance._is_fitted = True 717 718 return instance
Reconstruct a fitted SHAPRelativities from to_dict() output.
The reconstructed object has no model attached, so validate() and plot_relativities() still work but fit() cannot be re-run.
Arguments:
- data: Output of to_dict().
Returns:
Fitted SHAPRelativities instance.
46def extract_relativities( 47 model: Any, 48 X: Any, 49 exposure: Any = None, 50 categorical_features: list[str] | None = None, 51 base_levels: dict[str, str | float | int] | None = None, 52 ci_method: str = "clt", 53) -> pl.DataFrame: 54 """ 55 One-shot extraction of SHAP relativities from a tree model. 56 57 Wraps SHAPRelativities.fit() and extract_relativities() for cases where 58 you don't need the intermediate object. 59 60 Args: 61 model: Trained CatBoost model with a log-link objective (Poisson, 62 Tweedie, or Gamma). CatBoost is the recommended choice - it handles 63 categorical features natively without encoding. 64 X: Feature matrix. Accepts a Polars or pandas DataFrame. Polars is 65 preferred; pandas is accepted and converted internally. 66 exposure: Earned policy years. If None, all observations are equally 67 weighted. 68 categorical_features: Features to aggregate by level. If None, all 69 non-numeric columns are treated as categorical. 70 base_levels: Base level for each categorical feature (gets 71 relativity = 1.0). 72 ci_method: "clt" (default) or "none". 73 74 Returns: 75 Polars DataFrame with columns: feature, level, relativity, lower_ci, 76 upper_ci, mean_shap, shap_std, n_obs, exposure_weight. 77 """ 78 sr = SHAPRelativities(model, X, exposure, categorical_features) 79 sr.fit() 80 return sr.extract_relativities(base_levels=base_levels, ci_method=ci_method)
One-shot extraction of SHAP relativities from a tree model.
Wraps SHAPRelativities.fit() and extract_relativities() for cases where you don't need the intermediate object.
Arguments:
- model: Trained CatBoost model with a log-link objective (Poisson, Tweedie, or Gamma). CatBoost is the recommended choice - it handles categorical features natively without encoding.
- X: Feature matrix. Accepts a Polars or pandas DataFrame. Polars is preferred; pandas is accepted and converted internally.
- exposure: Earned policy years. If None, all observations are equally weighted.
- categorical_features: Features to aggregate by level. If None, all non-numeric columns are treated as categorical.
- base_levels: Base level for each categorical feature (gets relativity = 1.0).
- ci_method: "clt" (default) or "none".
Returns:
Polars DataFrame with columns: feature, level, relativity, lower_ci, upper_ci, mean_shap, shap_std, n_obs, exposure_weight.