diff --git a/.gitignore b/.gitignore
index 940507b..9cf2050 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,6 +25,7 @@ xbooster.egg-info
tmp
python-workspace.code-workspace
uv.lock
+requirements.txt
examples/*.sql
examples/*.py
# Keep typings directory but ignore pycache
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 04696f8..b93ca22 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -3,7 +3,6 @@ repos:
rev: 0.6.11
hooks:
- id: uv-lock
- - id: uv-export
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
@@ -21,3 +20,11 @@ repos:
- id: ruff-check
args: [--fix]
- id: ruff-format
+
+ - repo: https://github.com/pre-commit/mirrors-mypy
+ rev: v1.14.1
+ hooks:
+ - id: mypy
+ additional_dependencies: [pandas-stubs]
+ args: [--config-file=pyproject.toml]
+ files: ^xbooster/
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c6728d3..4205433 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,87 @@
# Changelog
+## [0.2.8] - 2026-04-19
+
+### Added
+- **Fine-Tuning Support**: New `finetuner` module for incremental model updates
+ - `finetune_xgb()`, `finetune_lgb()`, `finetune_cb()` helper functions
+ - `FineTuneResult` dataclass with tree counts and feature metadata
+ - Supports both same-feature continued training and expanded-feature warm-start
+ - `n_base_trees` parameter on all three constructors for fine-tuning awareness
+ - `from_finetune_result()` classmethod on all constructors
+ - `summarize_score_sources()` method for base vs. fine-tuned contribution analysis
+ - `TreeSource` column in scorecard output (base/finetuned)
+ - Example notebook: `examples/finetuning-getting-started.ipynb`
+
+- **SHAP Integration**: SHAP-based scoring for all three libraries
+ - **XGBoost**: Native SHAP extraction using `pred_contribs=True`
+ - **LightGBM**: Native SHAP extraction using `pred_contrib=True`
+ - **CatBoost**: Native SHAP extraction using `get_feature_importance(type='ShapValues')`
+ - New `method="shap"` option in `predict_score()` and `predict_scores()` methods
+ - Feature-level score decomposition via `predict_scores(method="shap")`
+ - No external dependencies required (uses native SHAP implementations)
+ - Dedicated `shap_scorecard.py` module with centralized extraction functions
+
+### Changed
+- **Type Checking**: Migrated from `ty` to `mypy` with strict type safety
+ - Created type stubs for xgboost, catboost in `typings/`
+ - Updated lightgbm and scipy type stubs
+ - All source files and tests pass mypy with zero `type: ignore` comments
+ - Added mypy hook to `.pre-commit-config.yaml`
+
+- **SHAP Module Refactoring**: Simplified SHAP API and module structure
+ - Simplified `compute_shap_scores()` to only require `shap_values`, `base_value`, and `feature_names`
+ - Module accessible via `from xbooster import shap` for cleaner imports
+ - SHAP computation is optional and only performed when `method="shap"` is used
+ - Removed SHAP column from scorecard binning tables (cleaner scorecard structure)
+
+- **XGBoost Leaf Index Format**: `get_leafs()` now returns integer leaf indices
+ - Leaf indices returned as integers (7) instead of floats (7.0)
+ - Matches LightGBM behavior for consistency across constructors
+
+### Performance Improvements
+- **XGBoost Constructor Optimization** (PR #14, @RektPunk): Vectorized `construct_scorecard()`
+- **LightGBM Constructor Optimizations** (PRs #10, #11, #13, @RektPunk):
+ - Vectorized `construct_scorecard()`, `_convert_tree_to_points()`, and `get_leafs()`
+ - All optimizations maintain backward compatibility and numerical equivalence
+
+### Fixed
+- **Package Distribution**: Excluded examples directory from sdist to reduce package size (~8.1MB reduction)
+
+### Technical Details
+- 144 tests passing (108 existing + 36 fine-tuning)
+- All three constructors support SHAP and fine-tuning: `XGBScorecardConstructor`, `LGBScorecardConstructor`, `CBScorecardConstructor`
+- Backward compatible: all existing APIs unchanged
+- Full mypy coverage with custom type stubs (no `type: ignore` suppression)
+
+## [0.2.8a1] - 2025-12-04 (Alpha)
+
+### Added
+- **SHAP Integration (Alpha)**: Added SHAP-based scoring for all three libraries
+ - **XGBoost**: Native SHAP extraction using `pred_contribs=True`
+ - **LightGBM**: Native SHAP extraction using `pred_contrib=True`
+ - **CatBoost**: Native SHAP extraction using `get_feature_importance(type='ShapValues')`
+ - New `method="shap"` option in `predict_score()` and `predict_scores()` methods
+ - SHAP values computed on-demand (not stored in scorecard binning table)
+ - Feature-level score decomposition via `predict_scores(method="shap")`
+ - Particularly useful for models with `max_depth > 1` where interpretability is challenging
+ - No external dependencies required (uses native SHAP implementations)
+
+### Changed
+- **SHAP Architecture Refactoring**: Moved all SHAP logic to dedicated `shap_scorecard.py` module
+ - SHAP extraction functions centralized: `extract_shap_values_xgb()`, `extract_shap_values_lgb()`, `extract_shap_values_cb()`
+ - SHAP computation is now optional and only performed when `method="shap"` is used
+ - Removed SHAP column from scorecard binning tables (cleaner scorecard structure)
+ - Simplified API: users don't need to import or call SHAP extraction functions directly
+
+### Technical Details
+- All three constructors now support SHAP: `XGBScorecardConstructor`, `LGBScorecardConstructor`, `CatBoostScorecardConstructor`
+- SHAP values computed using native library methods (no shap package dependency)
+- SHAP computation happens on-demand when `predict_score(method="shap")` or `predict_scores(method="shap")` is called
+- Backward compatible: traditional scorecard methods unchanged
+- Cleaner separation of concerns: scorecard construction vs. SHAP computation
+- Alpha release for testing and feedback
+
## [0.2.7] - 2025-12-04
### Changed
diff --git a/README.md b/README.md
index 1b4b91f..e30d926 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,21 @@
# xbooster 🚀
-A scorecard-format framework for logistic regression tasks with gradient-boosted decision trees (XGBoost, LightGBM, and CatBoost).
+
+
+
+
+
+
+[](https://badge.fury.io/py/xbooster)
+[](https://www.python.org/downloads/)
+[](https://www.python.org/downloads/)
+[](https://opensource.org/licenses/MIT)
+[](https://github.com/xRiskLab/xBooster/actions/workflows/ci.yml)
+[](https://pypi.org/project/xbooster/)
+
+
+
+A scorecard framework for credit scoring tasks with gradient-boosted decision trees (XGBoost, LightGBM, and CatBoost).
xbooster allows to convert a classification model into a logarithmic (point) scoring system.
In addition, it provides a suite of interpretability tools to understand the model's behavior.
@@ -10,6 +25,7 @@ The interpretability suite includes:
- Granular boosted tree statistics, including metrics such as Weight of Evidence (WOE) and Information Value (IV) for splits 🌳
- Tree visualization with customizations 🎨
- Global and local feature importance 📊
+- SHAP-based scoring for models with `max_depth > 1` 🧩
xbooster also provides a scorecard deployment using SQL 📦.
@@ -142,6 +158,125 @@ sql_query = scorecard_constructor.generate_sql_query(table_name='my_table')
print(sql_query)
```
+### SHAP-Based Scoring 🎯
+
+xbooster supports SHAP-based scoring for all three libraries (XGBoost, LightGBM, and CatBoost). This is particularly useful for models with `max_depth > 1` where traditional scorecard interpretability is challenging.
+
+**Key Features:**
+- **Native SHAP extraction** - No external `shap` package required
+- **On-demand computation** - SHAP values are computed only when needed
+- **Feature-level decomposition** - Understand individual feature contributions
+- **Consistent API** - Same interface across all three libraries
+
+**Usage:**
+
+```python
+# Predict scores using SHAP method (no binning table needed)
+shap_scores = scorecard_constructor.predict_score(X_test, method="shap")
+
+# Decompose scores by feature using SHAP
+shap_decomposed = scorecard_constructor.predict_scores(X_test, method="shap")
+print(shap_decomposed.head())
+# Output: DataFrame with columns like 'age_score', 'income_score', ..., 'score'
+
+# Compare with traditional scorecard-based scoring
+traditional_scores = scorecard_constructor.predict_score(X_test) # Default method
+```
+
+**How it works:**
+- SHAP values are computed on-the-fly using native library methods:
+ - XGBoost: `pred_contribs=True`
+ - LightGBM: `pred_contrib=True`
+ - CatBoost: `get_feature_importance(type='ShapValues')`
+- Values are automatically scaled using PDO (Points to Double the Odds) formula
+- No need to call `create_points()` first - SHAP scoring works independently
+- SHAP values are **not** stored in the scorecard binning table (computed only when needed)
+
+**Intercept and Offset Distribution:**
+
+By default, xbooster distributes the intercept (base value) and offset across all features when computing feature-level scores, matching the behavior of SAS scorecard modeling. This ensures that:
+
+1. Each feature score includes its proportional share of the intercept and offset
+2. The sum of all feature scores equals the total score (accounting for rounding)
+3. The decomposition is consistent with industry-standard scorecard practices
+
+This approach follows the SAS Enterprise Miner methodology for scorecard construction, where the base score is distributed across features rather than applied as a single constant. For more details, see the [SAS Enterprise Miner documentation](https://documentation.sas.com/doc/en/emref/15.4/n181vl3wdwn89mn1pfpqm3w6oaz5.htm).
+
+You can control this behavior using the `intercept_based` parameter:
+
+```python
+# Default: distribute intercept and offset across features (SAS-like behavior)
+shap_decomposed = scorecard_constructor.predict_scores(X_test, method="shap", intercept_based=True)
+
+# Alternative: apply intercept and offset once to the total score
+shap_decomposed = scorecard_constructor.predict_scores(X_test, method="shap", intercept_based=False)
+```
+
+**Example with all three libraries:**
+
+```python
+# XGBoost
+xgb_scores_shap = xgb_constructor.predict_score(X_test, method="shap")
+
+# LightGBM
+lgb_scores_shap = lgb_constructor.predict_score(X_test, method="shap")
+
+# CatBoost
+cb_scores_shap = cb_constructor.predict_score(X_test, method="shap")
+```
+
+For detailed examples, see the [SHAP Scorecard Examples notebook](examples/shap-scorecard-examples.ipynb).
+
+### Fine-Tuning Support
+
+xbooster provides helpers for incremental model updates — freeze base trees and append new ones, or warm-start with expanded features.
+
+**Same features (continued training):**
+
+```python
+from xbooster.finetuner import finetune_xgb
+
+# Fine-tune: base trees are frozen, 50 new trees appended
+result = finetune_xgb(base_model, X_new, y_new, n_estimators=50)
+
+print(f"Base trees: {result.n_base_trees}")
+print(f"Total trees: {result.n_total_trees}")
+```
+
+**Expanded features (warm-start):**
+
+```python
+# X_expanded has original + new columns
+result = finetune_xgb(base_model, X_expanded, y_new, n_estimators=50)
+
+print(f"New features: {result.new_features}")
+# n_base_trees=0 since base trees can't use new features
+```
+
+**Build a scorecard from the fine-tuned model:**
+
+```python
+from xbooster.constructor import XGBScorecardConstructor
+
+# Option 1: from FineTuneResult
+constructor = XGBScorecardConstructor.from_finetune_result(result, X_new, y_new)
+
+# Option 2: pass n_base_trees directly
+constructor = XGBScorecardConstructor(result.model, X_new, y_new, n_base_trees=result.n_base_trees)
+
+scorecard = constructor.construct_scorecard()
+
+# Scorecard now has a TreeSource column (base/finetuned)
+print(scorecard[["Tree", "Feature", "TreeSource"]].head())
+
+# See contribution split between base and fine-tuned trees
+print(constructor.summarize_score_sources())
+```
+
+The same API is available for LightGBM (`finetune_lgb`) and CatBoost (`finetune_cb`).
+
+For a complete walkthrough, see the [Fine-Tuning Getting Started notebook](examples/finetuning-getting-started.ipynb).
+
### Interval Scorecards 📊
Convert complex tree-based scorecards into simplified interval-based rules. This feature requires `max_depth=1` models and follows industry standard practices (Siddiqi, 2017):
@@ -339,11 +474,7 @@ model = CatBoostClassifier(
model.fit(pool)
# Create and fit the scorecard constructor
-constructor = CatBoostScorecardConstructor(model, pool) # use_woe=False is the default, using raw XAddEvidence
-
-# Alternatively, to use WOE values instead of raw XAddEvidence:
-# constructor = CatBoostScorecardConstructor(model, pool, use_woe=True)
-
+constructor = CatBoostScorecardConstructor(model, pool)
# Construct the scorecard
scorecard = constructor.construct_scorecard()
print("\nScorecard:")
@@ -456,152 +587,6 @@ visualizer = CatBoostTreeVisualizer(scorecard, plot_config)
visualizer.plot_tree(tree_idx=0, title="Customized Tree Visualization")
```
-## Parameters 🛠
-
-### `xbooster.constructor` - XGBoost Scorecard Constructor
-
-### Description
-
-A class for generating a scorecard from a trained XGBoost model. The methodology is inspired by the NVIDIA GTC Talk "Machine Learning in Retail Credit Risk" by Paul Edwards.
-
-### Methods
-
-1. `extract_leaf_weights() -> pd.DataFrame`:
- - Extracts the leaf weights from the booster's trees and returns a DataFrame.
- - **Returns**:
- - `pd.DataFrame`: DataFrame containing the extracted leaf weights.
-
-2. `extract_decision_nodes() -> pd.DataFrame`:
- - Extracts the split (decision) nodes from the booster's trees and returns a DataFrame.
- - **Returns**:
- - `pd.DataFrame`: DataFrame containing the extracted split (decision) nodes.
-
-3. `construct_scorecard() -> pd.DataFrame`:
- - Constructs a scorecard based on a booster.
- - **Returns**:
- - `pd.DataFrame`: The constructed scorecard.
-
-4. `create_points(pdo=50, target_points=600, target_odds=19, precision_points=0, score_type='XAddEvidence') -> pd.DataFrame`:
- - Creates a points card from a scorecard.
- - **Parameters**:
- - `pdo` (int, optional): The points to double the odds. Default is 50.
- - `target_points` (int, optional): The standard scorecard points. Default is 600.
- - `target_odds` (int, optional): The standard scorecard odds. Default is 19.
- - `precision_points` (int, optional): The points decimal precision. Default is 0.
- - `score_type` (str, optional): The log-odds to use for the points card. Default is 'XAddEvidence'.
- - **Returns**:
- - `pd.DataFrame`: The points card.
-
-5. `predict_score(X: pd.DataFrame) -> pd.Series`:
- - Predicts the score for a given dataset using the constructed scorecard.
- - **Parameters**:
- - `X` (`pd.DataFrame`): Features of the dataset.
- - **Returns**:
- - `pd.Series`: Predicted scores.
-
-6. `sql_query` (property):
- - Property that returns the SQL query for deploying the scorecard.
- - **Returns**:
- - `str`: The SQL query for deploying the scorecard.
-
-7. `generate_sql_query(table_name: str = "my_table") -> str`:
- - Converts a scorecard into an SQL format.
- - **Parameters**:
- - `table_name` (str): The name of the input table in SQL.
- - **Returns**:
- - `str`: The final SQL query for deploying the scorecard.
-
-8. `construct_scorecard_by_intervals(add_stats=True) -> pd.DataFrame`:
- - Constructs a scorecard grouped by intervals of the type [a, b). Requires max_depth=1 models.
- - **Parameters**:
- - `add_stats` (bool, optional): Whether to include WOE, IV, and count statistics. Default is True.
- - **Returns**:
- - `pd.DataFrame`: The interval-based scorecard.
-
-9. `create_points_peo_pdo(peo: int, pdo: int, precision_points: int = 0, scorecard: pd.DataFrame = None) -> pd.DataFrame`:
- - Creates Points at Even Odds/Points to Double the Odds (PEO/PDO) on interval scorecards.
- - **Parameters**:
- - `peo` (int): Points at Even Odds.
- - `pdo` (int): Points to Double the Odds.
- - `precision_points` (int, optional): Decimal precision for points. Default is 0.
- - `scorecard` (pd.DataFrame, optional): Specific scorecard to use. Default uses interval scorecard.
- - **Returns**:
- - `pd.DataFrame`: Scorecard with PEO/PDO points.
-
-### `xbooster.explainer` - XGBoost Scorecard Explainer
-
-This module provides functionalities for explaining XGBoost scorecards, including methods to extract split information, build interaction splits, visualize tree structures, plot feature importances, and more.
-
-### Methods:
-
-1. `extract_splits_info(features: str) -> list`:
- - Extracts split information from the DetailedSplit feature.
- - **Inputs**:
- - `features` (str): A string containing split information.
- - **Outputs**:
- - Returns a list of tuples containing split information (feature, sign, value).
-
-2. `build_interactions_splits(scorecard_constructor: Optional[XGBScorecardConstructor] = None, dataframe: Optional[pd.DataFrame] = None) -> pd.DataFrame`:
- - Builds interaction splits from the XGBoost scorecard.
- - **Inputs**:
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `dataframe` (Optional[pd.DataFrame]): The dataframe containing split information.
- - **Outputs**:
- - Returns a pandas DataFrame containing interaction splits.
-
-3. `split_and_count(scorecard_constructor: Optional[XGBScorecardConstructor] = None, dataframe: Optional[pd.DataFrame] = None, label_column: Optional[str] = None) -> pd.DataFrame`:
- - Splits the dataset and counts events for each split.
- - **Inputs**:
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `dataframe` (Optional[pd.DataFrame]): The dataframe containing features and labels.
- - `label_column` (Optional[str]): The label column in the dataframe.
- - **Outputs**:
- - Returns a pandas DataFrame containing split information and event counts.
-
-4. `plot_importance(scorecard_constructor: Optional[XGBScorecardConstructor] = None, metric: str = "Likelihood", normalize: bool = True, method: Optional[str] = None, dataframe: Optional[pd.DataFrame] = None, **kwargs: Any) -> None`:
- - Plots the importance of features based on the XGBoost scorecard.
- - **Inputs**:
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `metric` (str): Metric to plot ("Likelihood" (default), "NegLogLikelihood", "IV", or "Points").
- - `normalize` (bool): Whether to normalize the importance values (default: True).
- - `method` (Optional[str]): The method to use for plotting the importance ("global" or "local").
- - `dataframe` (Optional[pd.DataFrame]): The dataframe containing features and labels.
- - `fontfamily` (str): The font family to use for the plot (default: "Monospace").
- - `fontsize` (int): The font size to use for the plot (default: 12).
- - `dpi` (int): The DPI of the plot (default: 100).
- - `title` (str): The title of the plot (default: "Feature Importance").
- - `**kwargs` (Any): Additional Matplotlib parameters.
-
-5. `plot_score_distribution(y_true: pd.Series = None, y_pred: pd.Series = None, n_bins: int = 25, scorecard_constructor: Optional[XGBScorecardConstructor] = None, **kwargs: Any)`:
- - Plots the distribution of predicted scores based on actual labels.
- - **Inputs**:
- - `y_true` (pd.Series): The true labels.
- - `y_pred` (pd.Series): The predicted labels.
- - `n_bins` (int): Number of bins for histogram (default: 25).
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `**kwargs` (Any): Additional Matplotlib parameters.
-
-6. `plot_local_importance(scorecard_constructor: Optional[XGBScorecardConstructor] = None, metric: str = "Likelihood", normalize: bool = True, dataframe: Optional[pd.DataFrame] = None, **kwargs: Any) -> None`:
- - Plots the local importance of features based on the XGBoost scorecard.
- - **Inputs**:
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `metric` (str): Metric to plot ("Likelihood" (default), "NegLogLikelihood", "IV", or "Points").
- - `normalize` (bool): Whether to normalize the importance values (default: True).
- - `dataframe` (Optional[pd.DataFrame]): The dataframe containing features and labels.
- - `fontfamily` (str): The font family to use for the plot (default: "Arial").
- - `fontsize` (int): The font size to use for the plot (default: 12).
- - `boxstyle` (str): The rounding box style to use for the plot (default: "round").
- - `title` (str): The title of the plot (default: "Local Feature Importance").
- - `**kwargs` (Any): Additional parameters to pass to the matplotlib function.
-
-7. `plot_tree(tree_index: int, scorecard_constructor: Optional[XGBScorecardConstructor] = None, show_info: bool = True) -> None`:
- - Plots the tree structure.
- - **Inputs**:
- - `tree_index` (int): Index of the tree to plot.
- - `scorecard_constructor` (Optional[XGBScorecardConstructor]): The XGBoost scorecard constructor.
- - `show_info` (bool): Whether to show additional information (default: True).
- - `**kwargs` (Any): Additional Matplotlib parameters.
-
## Contributing 🤝
Contributions are welcome! For bug reports or feature requests, please open an issue.
diff --git a/docs/likelihood_boosting.md b/docs/likelihood_boosting.md
new file mode 100644
index 0000000..8e18bda
--- /dev/null
+++ b/docs/likelihood_boosting.md
@@ -0,0 +1,320 @@
+# The Likelihoodist Interpretation of Gradient Boosting
+
+> **Author:** Denis Burakov | **Date:** November 2023
+
+This document explores a likelihood-based perspective on gradient boosting machines, conceptualizing tree margins as additive evidence in favor of an event hypothesis.
+
+## 1. Introduction
+
+Gradient boosting algorithms have emerged as powerful tools in machine learning, demonstrating exceptional performance across classification and regression tasks. While celebrated for their predictive prowess, their inner workings often remain elusive. This paper presents a *likelihoodist interpretation* of gradient boosting margins, providing insights into feature selection, model optimization, and interpretability.
+
+The key insight is that gradient boosted trees can be viewed as aggregations of margins, where each boosting iteration contributes a **likelihood** relative to a base score. This perspective enables us to assess the importance of individual splits within tree interactions using **likelihood ratios**.
+
+## 2. Background: Weight of Evidence
+
+### 2.1 Definition
+
+The Weight of Evidence (WOE), introduced by I.J. Good (1950), measures the evidence in favor of a hypothesis. For a binary classification problem, WOE quantifies how much a given split favors the event class:
+
+$$
+\text{WOE} = \ln\left(\frac{P(\text{Event} | \text{Split})}{P(\text{Non-Event} | \text{Split})} \cdot \frac{P(\text{Non-Event})}{P(\text{Event})}\right)
+$$
+
+This simplifies to:
+
+$$
+\text{WOE} = \ln\left(\frac{\text{Events} / \Sigma\text{Events}}{\text{NonEvents} / \Sigma\text{NonEvents}}\right)
+$$
+
+### 2.2 Relationship to Odds
+
+The WOE can also be expressed in terms of odds:
+
+$$
+\text{WOE} = \ln\left(\frac{\text{Odds}_{\text{split}}}{\text{Odds}_{\text{prior}}}\right)
+$$
+
+where:
+- $\text{Odds}_{\text{split}} = \frac{\text{EventRate}_{\text{split}}}{1 - \text{EventRate}_{\text{split}}}$
+- $\text{Odds}_{\text{prior}} = \frac{\text{EventRate}_{\text{global}}}{1 - \text{EventRate}_{\text{global}}}$
+
+### 2.3 Likelihood from WOE
+
+The likelihood is simply the exponentiated WOE:
+
+$$
+\mathcal{L} = e^{\text{WOE}}
+$$
+
+This converts the log-odds ratio back to a probability ratio, representing how much more (or less) likely the event is given the split condition.
+
+## 3. Gradient Boosting as Likelihood Aggregation
+
+### 3.1 The Margin Decomposition
+
+A gradient boosted tree ensemble produces a prediction as a sum of margins:
+
+$$
+\text{margin}(x) = b_0 + \sum_{t=1}^{T} w_t(x)
+$$
+
+where:
+- $b_0$ is the base score (initial log-odds / prior)
+- $w_t(x)$ is the leaf weight from tree $t$ for observation $x$
+- $T$ is the number of trees
+
+In xBooster, the leaf weight is stored as `XAddEvidence` (additive evidence):
+
+```python
+# From xbooster/xgb_constructor.py
+scorecard["XAddEvidence"] = leaf_weights # Per-tree margin contribution
+```
+
+### 3.2 Interpreting Margins as Likelihoods
+
+Each margin $w_t$ can be interpreted as a likelihood update relative to the prior:
+
+$$
+\mathcal{L}_t = e^{w_t}
+$$
+
+The boosting process iteratively updates the previous likelihood by fitting new decision trees. The final prediction aggregates these likelihoods:
+
+$$
+\text{Odds}_{\text{final}} = \text{Odds}_{\text{prior}} \times \prod_{t=1}^{T} \mathcal{L}_t = \text{Odds}_{\text{prior}} \times e^{\sum_t w_t}
+$$
+
+## 4. Example: Analyzing a Split
+
+### 4.1 Two-Feature Interaction
+
+Consider a split in a gradient boosting tree:
+
+| Field | Value |
+|-------|-------|
+| Tree | 0 |
+| Node | 3 |
+| Feature | revolving_utilization |
+| Sign | < |
+| Split | 0.60931 |
+| Count | 1901 |
+| CountPct | 27.16% |
+| NonEvents | 1669 |
+| Events | 232 |
+| EventRate | 12.20% |
+| WOE | 0.224 |
+| XAddEvidence | -0.1801 |
+| DetailedSplit | account_never_delinq_percent < 98, revolving_utilization < 0.609 |
+
+This is a depth-2 tree forming a two-way interaction. The event rate (12.20%) is lower than the base rate (16%), indicating lower risk for this segment.
+
+### 4.2 Calculating Likelihood from Event Rates
+
+Given:
+- Prior odds: $0.10 / (1 - 0.10) = 0.111$
+- Split event rate: 12.20%
+- Split odds: $0.122 / (1 - 0.122) = 0.139$
+
+The likelihood is:
+
+$$
+\mathcal{L} = \frac{\text{Odds}_{\text{split}}}{\text{Odds}_{\text{prior}}} = \frac{0.139}{0.111} = 1.251
+$$
+
+Taking the natural logarithm:
+
+$$
+\text{WOE} = \ln(1.251) = 0.224
+$$
+
+This matches the WOE value in the scorecard table.
+
+### 4.3 Base Score Difference
+
+The XAddEvidence is negative (-0.1801) while WOE is positive (0.224). This is because XGBoost uses a different base score (16%) compared to the sample average (10%). The direction of the evidence depends on the reference point.
+
+## 5. Likelihood Ratios for Feature Importance
+
+### 5.1 The Problem with Interactions
+
+When a tree has `max_depth > 1`, each leaf represents an interaction of multiple features. For example:
+
+```
+account_never_delinq_percent < 98 AND revolving_utilization < 0.609
+```
+
+The final likelihood is attributed to the last split (revolving_utilization), but the first split (account_never_delinq_percent) may have a larger impact.
+
+### 5.2 Decomposing Split Contributions
+
+To measure the relative importance of each split, we compute individual likelihoods:
+
+| Feature | Split | EventRate | Odds | Likelihood |
+|---------|-------|-----------|------|------------|
+| account_never_delinq_percent | < 98 | 23.99% | 0.316 | 2.840 |
+| revolving_utilization | < 0.609 | 4.56% | 0.048 | 0.430 |
+| *Leaf (combined)* | — | 12.20% | 0.139 | 1.251 |
+
+The first split (account_never_delinq_percent < 98) has a likelihood of 2.84, which is much larger than the second split (0.43).
+
+### 5.3 Computing the Likelihood Ratio
+
+The likelihood ratio compares each split's likelihood to the final leaf likelihood:
+
+$$
+\text{LR} = \frac{\mathcal{L}_{\text{leaf}}}{\mathcal{L}_{\text{split}}}
+$$
+
+Equivalently, using WOE:
+
+$$
+\text{LR} = e^{\text{WOE}_{\text{split}} - \text{WOE}_{\text{leaf}}}
+$$
+
+| Feature | $\mathcal{L}_{\text{leaf}}$ | $\mathcal{L}_{\text{split}}$ | LR |
+|---------|------|------|------|
+| account_never_delinq_percent | 1.251 | 2.840 | 0.441 |
+| revolving_utilization | 1.251 | 0.430 | 2.913 |
+
+**Interpretation**:
+- A likelihood ratio < 1 means the split alone is *more* predictive than the leaf (the split condition alone has higher event rate than the combined condition)
+- A likelihood ratio > 1 means the split alone is *less* predictive than the leaf
+
+In this example, the revolving_utilization split has a higher LR (2.91), meaning its individual contribution deviates more from the final leaf—indicating it's more important for determining this specific segment's risk.
+
+## 6. Implementation in xBooster
+
+### 6.1 WOE Calculation
+
+The `calculate_weight_of_evidence` function in [`xbooster/_utils.py`](../xbooster/_utils.py) implements Good's formula:
+
+```python
+def calculate_weight_of_evidence(xgb_scorecard: pd.DataFrame) -> pd.DataFrame:
+ """
+ Calculate WOE using Good's formula from Bayes factor.
+ The use of event to non-event ratio aligns with XAddEvidence direction.
+ """
+ woe_table = xgb_scorecard.copy()
+
+ # Calculate cumulative totals
+ woe_table["CumNonEvents"] = woe_table.groupby("Tree")["NonEvents"].transform("sum")
+ woe_table["CumEvents"] = woe_table.groupby("Tree")["Events"].transform("sum")
+
+ # WOE = ln(Events/ΣEvents) - ln(NonEvents/ΣNonEvents)
+ woe_table["WOE"] = np.log(
+ (woe_table["Events"] / woe_table["CumEvents"]) /
+ (woe_table["NonEvents"] / woe_table["CumNonEvents"])
+ )
+ return woe_table
+```
+
+### 6.2 Likelihood Calculation
+
+```python
+def calculate_likelihood(xgb_scorecard: pd.DataFrame) -> pd.Series:
+ """Convert WOE to likelihood by exponentiating."""
+ woe_table = calculate_information_value(xgb_scorecard)
+ woe_table["Likelihood"] = np.exp(woe_table["WOE"])
+ return pd.Series(woe_table["Likelihood"], name="Likelihood")
+```
+
+### 6.3 Scorecard Construction
+
+The `construct_scorecard()` method produces a table with both WOE and XAddEvidence:
+
+```python
+from xbooster.xgb_constructor import XGBScorecardConstructor
+
+# Build scorecard
+constructor = XGBScorecardConstructor(model, X_train, y_train)
+scorecard = constructor.construct_scorecard()
+
+# Key columns available:
+# - XAddEvidence: Leaf weight (margin contribution)
+# - WOE: Weight of Evidence
+# - IV: Information Value
+# - DetailedSplit: Full path condition
+print(scorecard[["Tree", "Node", "Feature", "XAddEvidence", "WOE"]].head())
+```
+
+## 7. Practical Applications
+
+### 7.1 Feature Selection
+
+The likelihood ratio provides a principled way to rank features within an interaction:
+- Features with LR closer to 1 contribute proportionally to the final prediction
+- Features with LR far from 1 are more/less important than their leaf context suggests
+
+### 7.2 Model Interpretation
+
+Viewing margins as likelihoods enables:
+- **Uncertainty quantification**: Each tree contributes a likelihood update
+- **Feature importance**: Likelihood ratios reveal which splits drive predictions
+- **Model diagnostics**: Compare WOE direction with XAddEvidence direction
+
+### 7.3 Comparison with SHAP
+
+| Aspect | Likelihoodist View | SHAP View |
+|--------|-------------------|-----------|
+| Decomposition | By tree/split | By feature |
+| Base value | Prior odds | Expected value |
+| Contribution | Likelihood update | Additive attribution |
+| Interactions | Natural via LR | Captured in feature values |
+| Reference | Statistical tradition | Game theory |
+
+Both approaches are complementary: the likelihoodist view excels at understanding tree structure and split importance, while SHAP provides feature-level attribution.
+
+## 8. Mathematical Summary
+
+### 8.1 Key Formulas
+
+**Weight of Evidence:**
+
+$$
+\text{WOE} = \ln\left(\frac{P(\text{Event}|\text{Split}) / P(\text{NonEvent}|\text{Split})}{P(\text{Event}) / P(\text{NonEvent})}\right)
+$$
+
+**Likelihood:**
+
+$$
+\mathcal{L} = e^{\text{WOE}}
+$$
+
+**Gradient Boosting Prediction:**
+
+$$
+\text{margin}(x) = b_0 + \sum_{t=1}^{T} w_t(x)
+$$
+
+**Likelihood Ratio:**
+
+$$
+\text{LR} = \frac{\mathcal{L}_{\text{leaf}}}{\mathcal{L}_{\text{split}}} = e^{\text{WOE}_{\text{split}} - \text{WOE}_{\text{leaf}}}
+$$
+
+### 8.2 The Additivity Property
+
+In log space, likelihoods are additive:
+
+$$
+\ln(\text{Odds}_{\text{final}}) = \ln(\text{Odds}_{\text{prior}}) + \sum_{t=1}^{T} w_t
+$$
+
+This is precisely what gradient boosting computes, making the likelihoodist interpretation natural.
+
+## 9. Conclusion
+
+The likelihoodist interpretation of gradient boosting margins offers a principled statistical framework for understanding these powerful algorithms. By viewing each tree's contribution as a likelihood update, we gain:
+
+1. **Theoretical clarity**: Connect boosting to classical statistical inference
+2. **Practical tools**: Likelihood ratios for feature importance within interactions
+3. **Interpretability**: Natural probabilistic interpretation of model components
+
+This perspective complements SHAP-based interpretability and enriches both the theoretical foundations and practical applications of gradient boosting.
+
+## References
+
+1. Good, I. J. (1950). *Probability and the Weighing of Evidence*. Griffin.
+2. Friedman, J. H. (2001). Greedy function approximation: A gradient boosting machine. *Annals of Statistics*.
+3. Chen, T., & Guestrin, C. (2016). XGBoost: A scalable tree boosting system. *KDD*.
+4. Siddiqi, N. (2017). *Intelligent Credit Scoring*. Wiley.
diff --git a/docs/shap_scorecards.md b/docs/shap_scorecards.md
new file mode 100644
index 0000000..64a8e5b
--- /dev/null
+++ b/docs/shap_scorecards.md
@@ -0,0 +1,243 @@
+# SHAP Scorecards
+
+> **Author:** Denis Burakov | **Date:** December 2025
+
+This document explains how xBooster uses TreeSHAP to create scorecards for gradient-boosted trees, and the mathematical relationship between feature-based SHAP decomposition and tree-based margin decomposition.
+
+## 1. SHAP-Based Scoring with `predict_score` and `predict_scores`
+
+### 1.1 TreeSHAP Decomposition
+
+For a gradient-boosted tree model, TreeSHAP decomposes the model's prediction into per-feature contributions. For an observation $x$, the model's log-odds (margin) can be written as:
+
+$$
+\text{margin}(x) = \phi_0 + \sum_{j=1}^{p} \phi_j(x)
+$$
+
+where:
+- $\phi_0$ is the base value (expected value of the model output)
+- $\phi_j(x)$ is the SHAP contribution for feature $j$
+- $p$ is the number of features
+
+The key property of SHAP values is that they sum to the difference between the prediction and the base value:
+
+$$
+\sum_{j=1}^{p} \phi_j(x) = \text{margin}(x) - \phi_0
+$$
+
+### 1.2 PDO Scaling
+
+To convert log-odds to a scorecard scale, we use the Points to Double the Odds (PDO) method:
+
+$$
+\text{Score} = \text{Offset} - \text{Factor} \times \text{margin}
+$$
+
+where:
+- $\text{Factor} = \frac{\text{PDO}}{\ln(2)}$
+- $\text{Offset} = \text{Target Points} - \text{Factor} \times \ln(\text{Target Odds})$
+
+With default parameters (PDO=50, Target Points=600, Target Odds=19):
+- Factor ≈ 72.13
+- Offset ≈ 387.60
+
+### 1.3 Intercept Redistribution (SAS Method)
+
+When computing feature-level scores, we follow the [SAS approach for scorecard development](https://documentation.sas.com/doc/en/emref/15.4/n181vl3wdwn89mn1pfpqm3w6oaz5.htm), which distributes the intercept and offset evenly across all features:
+
+$$
+\text{Score}_j = \text{Factor} \times (-\phi_j) + \frac{-\text{Intercept}_{\text{scaled}}}{p} + \frac{\text{Offset}}{p}
+$$
+
+where:
+- $\text{Intercept}_{\text{scaled}} = \text{Factor} \times \phi_0$
+- $p$ is the number of features
+
+The total score is then:
+
+$$
+\text{Score}_{\text{total}} = \sum_{j=1}^{p} \text{round}(\text{Score}_j)
+$$
+
+**Important**: Each feature score is rounded first, then summed. This ensures that individual feature scores add up exactly to the total score, matching traditional scorecard behavior.
+
+### 1.4 Implementation in xBooster
+
+The `predict_score(method="shap")` method:
+
+```python
+# Extract SHAP values
+shap_values = extract_shap_values_xgb(model, X, base_score)
+feature_shap = shap_values[:, :-1] # Per-feature contributions
+base_value = shap_values[0, -1] # Base value (φ₀)
+
+# Scale to scorecard
+intercept_scaled = factor * base_value
+intercept_contribution = (-intercept_scaled) / n_features
+offset_contribution = offset / n_features
+
+for feature in features:
+ feature_score = factor * (-shap[feature]) + intercept_contribution + offset_contribution
+ feature_score = round(feature_score)
+
+total_score = sum(feature_scores)
+```
+
+The `predict_scores(method="shap")` method returns the decomposed scores per feature, allowing interpretability at the feature level.
+
+## 2. XAddEvidence: Per-Tree Margin Contributions
+
+### 2.1 Tree-Based Margin Decomposition
+
+An alternative decomposition is by tree rather than by feature. For an ensemble of $T$ trees:
+
+$$
+\text{margin}(x) = b_0 + \sum_{t=1}^{T} w_t(x)
+$$
+
+where $w_t(x)$ is the leaf weight (margin contribution) from tree $t$ for observation $x$.
+
+**Key property**: All observations that fall into the same leaf of tree $t$ receive the same contribution $w_t$. This makes the per-tree contribution deterministic per leaf.
+
+### 2.2 XAddEvidence in the Scorecard Table
+
+The scorecard table includes an `XAddEvidence` column that stores the per-tree margin contribution for each (Tree, Node) combination:
+
+| Tree | Node | Feature | Split | XAddEvidence |
+|------|------|---------|-------|--------------|
+| 0 | 4 | debt_ratio | >= 0.47 | 0.456 |
+| 0 | 6 | age | >= 30 | -0.119 |
+| ... | ... | ... | ... | ... |
+
+The XAddEvidence value for each leaf is:
+- **Deterministic**: All observations in the same leaf get the same value
+- **Additive**: Summing across all trees gives the total margin contribution
+
+### 2.3 Base Value Adjustment
+
+There's a subtle difference between the base values:
+- **Constructor base_score** ($b_0$): The model's initial prediction (prior log-odds)
+- **SHAP base_value** ($\phi_0$): TreeSHAP's expected value
+
+To relate XAddEvidence to feature SHAP values, apply an adjustment:
+
+$$
+w_t^{\text{adj}} = w_t + \frac{b_0 - \phi_0}{T}
+$$
+
+This distributes the base value difference across all trees, ensuring:
+
+$$
+\sum_{t=1}^{T} w_t^{\text{adj}} = \sum_{j=1}^{p} \phi_j
+$$
+
+### 2.4 Computing Scores from the Table
+
+To compute a score for observation $x$ using the table:
+
+1. **Find leaf indices**: For each tree $t$, determine which leaf $x$ falls into
+2. **Sum XAddEvidence**: $\text{margin}_x = \sum_{t=1}^{T} w_t + (b_0 - \phi_0)$
+3. **Apply PDO scaling**: $\text{Score} = \text{Factor} \times (-\text{margin}_x) - \text{Intercept}_{\text{scaled}} + \text{Offset}$
+4. **Round**: $\text{Score} = \text{round}(\text{Score})$
+
+```python
+# Sum XAddEvidence from table across all trees
+total_margin = 0
+for tree_idx in range(n_trees):
+ node_idx = get_leaf_index(x, tree_idx)
+ total_margin += scorecard[(Tree == tree_idx) & (Node == node_idx)]["XAddEvidence"]
+
+# Add base value adjustment
+total_margin += (base_score - shap_base_value)
+
+# Apply PDO scaling
+score = factor * (-total_margin) - intercept_scaled + offset
+score = round(score)
+```
+
+## 3. Equivalence of the Two Methods
+
+### 3.1 The Fundamental Relationship
+
+Both methods decompose the same model margin:
+
+$$
+\underbrace{\sum_{j=1}^{p} \phi_j(x)}_{\text{Feature SHAP}} = \underbrace{\sum_{t=1}^{T} w_t^{\text{adj}}(x)}_{\text{XAddEvidence (adj)}} = \text{margin}(x) - \phi_0
+$$
+
+where $w_t^{\text{adj}}$ includes the base value adjustment.
+
+### 3.2 Why Scores Match
+
+When using the same scaling approach (no per-feature rounding):
+
+$$
+\text{Score}_{\text{feature}} = \text{Factor} \times \left(-\sum_{j=1}^{p} \phi_j\right) - \text{Intercept}_{\text{scaled}} + \text{Offset}
+$$
+
+$$
+\text{Score}_{\text{table}} = \text{Factor} \times \left(-\sum_{t=1}^{T} w_t^{\text{adj}}\right) - \text{Intercept}_{\text{scaled}} + \text{Offset}
+$$
+
+Since $\sum_j \phi_j = \sum_t w_t^{\text{adj}}$, the scores are identical before rounding.
+
+### 3.3 Rounding Differences
+
+The only difference arises from rounding order:
+
+| Method | Rounding | Result |
+|--------|----------|--------|
+| Feature SHAP (`intercept_based=True`) | Round each feature score, then sum | Integer feature scores that sum exactly |
+| XAddEvidence | Sum first, then round once | Single rounded total |
+
+This can cause ±1 point differences, which is acceptable for scorecard applications.
+
+### 3.4 Summary
+
+| Aspect | Feature SHAP | XAddEvidence |
+|--------|--------------|--------------|
+| Decomposition | By feature | By tree |
+| Deterministic per leaf? | No (varies by observation) | Yes (same for all in leaf) |
+| Interpretability | Per-feature contributions | Per-tree contributions |
+| Storage | Computed on-the-fly | Stored in scorecard table |
+| Scores | Via `predict_score(method="shap")` | Sum XAddEvidence + adjustment, scale |
+
+Both methods are mathematically equivalent and produce matching scores when using consistent base values and scaling approaches.
+
+## 4. Example Code
+
+A complete working example demonstrating the equivalence is available in [shap-in-leaf-weights.ipynb](../examples/shap-in-leaf-weights.ipynb).
+
+```python
+from xbooster.xgb_constructor import XGBScorecardConstructor
+from xbooster.shap_scorecard import extract_shap_values_xgb
+
+# Build scorecard
+constructor = XGBScorecardConstructor(model, X_train, y_train)
+scorecard = constructor.construct_scorecard()
+
+# Feature SHAP: sum across features
+shap_full = extract_shap_values_xgb(model, X_test, constructor.base_score, False)
+feature_shap_sum = shap_full[:, :-1].sum(axis=1)
+shap_base_value = shap_full[0, -1]
+
+# XAddEvidence: sum across trees with base value adjustment
+leaf_indices = constructor.get_leafs(X_test, output_type="leaf_index")
+base_adjustment = constructor.base_score - shap_base_value
+
+table_margin_sum = [
+ sum(scorecard[(scorecard["Tree"] == t) & (scorecard["Node"] == leafs.iloc[t])]["XAddEvidence"].iloc[0]
+ for t in range(n_trees)) + base_adjustment
+ for leafs in leaf_indices.itertuples(index=False)
+]
+
+# Both sums are equal → scores match
+assert np.allclose(feature_shap_sum, table_margin_sum)
+```
+
+Consult the example notebook [shap-in-leaf-weights.ipynb](../examples/shap-in-leaf-weights.ipynb) for a complete working example.
+
+## References
+
+1. Lundberg, S. M., & Lee, S. I. (2017). A unified approach to interpreting model predictions. *NeurIPS*.
+2. [SAS Scorecard Development Documentation](https://documentation.sas.com/doc/en/emref/15.4/n181vl3wdwn89mn1pfpqm3w6oaz5.htm)
diff --git a/examples/catboost-getting-started.ipynb b/examples/catboost-getting-started.ipynb
index 618bef2..01ed9f8 100644
--- a/examples/catboost-getting-started.ipynb
+++ b/examples/catboost-getting-started.ipynb
@@ -38,8 +38,8 @@
"name": "stderr",
"output_type": "stream",
"text": [
- "/var/folders/k_/yz8rvp25185_js60dw8vhnj40000gn/T/ipykernel_28148/1010937800.py:16: FutureWarning: Downcasting behavior in `replace` is deprecated and will be removed in a future version. To retain the old behavior, explicitly call `result.infer_objects(copy=False)`. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`\n",
- " y = credit_data[\"Final_Decision\"].replace({\"Accept\": 1, \"Decline\": 0})\n"
+ "/var/folders/k_/yz8rvp25185_js60dw8vhnj40000gn/T/ipykernel_37082/4142729875.py:16: FutureWarning: Downcasting behavior in `replace` is deprecated and will be removed in a future version. To retain the old behavior, explicitly call `result.infer_objects(copy=False)`. To opt-in to the future behavior, set `pd.set_option('future.no_silent_downcasting', True)`\n",
+ " y = credit_data[\"Final_Decision\"].replace({\"Accept\": 0, \"Decline\": 1})\n"
]
},
{
@@ -81,9 +81,10 @@
" NonEvents \n",
" Events \n",
" EventRate \n",
- " LeafValue \n",
+ " XAddEvidence \n",
" WOE \n",
" IV \n",
+ " SHAP \n",
" DetailedSplit \n",
" \n",
" \n",
@@ -97,12 +98,13 @@
" 782.5 \n",
" 0.000362 \n",
" 45.0 \n",
- " 34.0 \n",
" 11.0 \n",
- " 0.244444 \n",
- " -0.081 \n",
- " -3.328374 \n",
+ " 34.0 \n",
+ " 0.755556 \n",
+ " 0.081 \n",
+ " 3.328374 \n",
" 2.182738 \n",
+ " 1.974891 \n",
" Time_with_Bank != '402' AND Gross_Annual_Incom... \n",
" \n",
" \n",
@@ -114,12 +116,13 @@
" 782.5 \n",
" 0.002373 \n",
" 295.0 \n",
- " 25.0 \n",
" 270.0 \n",
- " 0.915254 \n",
- " 0.160 \n",
- " 0.179637 \n",
+ " 25.0 \n",
+ " 0.084746 \n",
+ " -0.160 \n",
+ " -0.179637 \n",
" 0.002697 \n",
+ " -0.021415 \n",
" Time_with_Bank != '402' AND Gross_Annual_Incom... \n",
" \n",
" \n",
@@ -131,12 +134,13 @@
" 782.5 \n",
" 0.000523 \n",
" 65.0 \n",
- " 43.0 \n",
" 22.0 \n",
- " 0.338462 \n",
- " -0.055 \n",
- " -2.870067 \n",
+ " 43.0 \n",
+ " 0.661538 \n",
+ " 0.055 \n",
+ " 2.870067 \n",
" 1.612346 \n",
+ " 2.049738 \n",
" Time_with_Bank != '402' AND Gross_Annual_Incom... \n",
" \n",
" \n",
@@ -145,14 +149,14 @@
],
"text/plain": [
" Tree LeafIndex Feature Sign Split CountPct Count NonEvents \\\n",
- "0 0 0 Application_Score <= 782.5 0.000362 45.0 34.0 \n",
- "1 0 1 Application_Score > 782.5 0.002373 295.0 25.0 \n",
- "2 0 2 Application_Score <= 782.5 0.000523 65.0 43.0 \n",
+ "0 0 0 Application_Score <= 782.5 0.000362 45.0 11.0 \n",
+ "1 0 1 Application_Score > 782.5 0.002373 295.0 270.0 \n",
+ "2 0 2 Application_Score <= 782.5 0.000523 65.0 22.0 \n",
"\n",
- " Events EventRate LeafValue WOE IV \\\n",
- "0 11.0 0.244444 -0.081 -3.328374 2.182738 \n",
- "1 270.0 0.915254 0.160 0.179637 0.002697 \n",
- "2 22.0 0.338462 -0.055 -2.870067 1.612346 \n",
+ " Events EventRate XAddEvidence WOE IV SHAP \\\n",
+ "0 34.0 0.755556 0.081 3.328374 2.182738 1.974891 \n",
+ "1 25.0 0.084746 -0.160 -0.179637 0.002697 -0.021415 \n",
+ "2 43.0 0.661538 0.055 2.870067 1.612346 2.049738 \n",
"\n",
" DetailedSplit \n",
"0 Time_with_Bank != '402' AND Gross_Annual_Incom... \n",
@@ -194,7 +198,7 @@
" \n",
" Tree \n",
" LeafIndex \n",
- " LeafValue \n",
+ " XAddEvidence \n",
" WOE \n",
" \n",
" \n",
@@ -203,29 +207,29 @@
" 0 \n",
" 0 \n",
" 0 \n",
- " -0.081 \n",
- " -3.328374 \n",
+ " 0.081 \n",
+ " 3.328374 \n",
" \n",
" \n",
" 1 \n",
" 0 \n",
" 1 \n",
- " 0.160 \n",
- " 0.179637 \n",
+ " -0.160 \n",
+ " -0.179637 \n",
" \n",
" \n",
" 2 \n",
" 0 \n",
" 2 \n",
- " -0.055 \n",
- " -2.870067 \n",
+ " 0.055 \n",
+ " 2.870067 \n",
" \n",
" \n",
" 3 \n",
" 0 \n",
" 3 \n",
- " 0.187 \n",
- " 1.448892 \n",
+ " -0.187 \n",
+ " -1.448892 \n",
" \n",
" \n",
" 4 \n",
@@ -238,8 +242,8 @@
" 5 \n",
" 0 \n",
" 5 \n",
- " 0.025 \n",
- " -1.101297 \n",
+ " -0.025 \n",
+ " 1.101297 \n",
" \n",
" \n",
" 6 \n",
@@ -252,15 +256,15 @@
" 7 \n",
" 0 \n",
" 7 \n",
- " 0.067 \n",
- " 4.706846 \n",
+ " -0.067 \n",
+ " -4.706846 \n",
" \n",
" \n",
" 8 \n",
" 1 \n",
" 0 \n",
- " -0.027 \n",
- " -2.738906 \n",
+ " 0.027 \n",
+ " 2.738906 \n",
" \n",
" \n",
" 9 \n",
@@ -274,79 +278,21 @@
""
],
"text/plain": [
- " Tree LeafIndex LeafValue WOE\n",
- "0 0 0 -0.081 -3.328374\n",
- "1 0 1 0.160 0.179637\n",
- "2 0 2 -0.055 -2.870067\n",
- "3 0 3 0.187 1.448892\n",
- "4 0 4 0.000 0.000000\n",
- "5 0 5 0.025 -1.101297\n",
- "6 0 6 0.000 0.000000\n",
- "7 0 7 0.067 4.706846\n",
- "8 1 0 -0.027 -2.738906\n",
- "9 1 1 0.000 0.000000"
+ " Tree LeafIndex XAddEvidence WOE\n",
+ "0 0 0 0.081 3.328374\n",
+ "1 0 1 -0.160 -0.179637\n",
+ "2 0 2 0.055 2.870067\n",
+ "3 0 3 -0.187 -1.448892\n",
+ "4 0 4 0.000 0.000000\n",
+ "5 0 5 -0.025 1.101297\n",
+ "6 0 6 0.000 0.000000\n",
+ "7 0 7 -0.067 -4.706846\n",
+ "8 1 0 0.027 2.738906\n",
+ "9 1 1 0.000 0.000000"
]
},
"metadata": {},
"output_type": "display_data"
- },
- {
- "name": "stdout",
- "output_type": "stream",
- "text": [
- "DEBUG - value_col: WOE\n",
- "DEBUG - scorecard columns: ['Tree', 'LeafIndex', 'Feature', 'Sign', 'Split', 'CountPct', 'Count', 'NonEvents', 'Events', 'EventRate', 'LeafValue', 'WOE', 'IV', 'DetailedSplit']\n",
- "DEBUG - WOE stats: count 800.000000\n",
- "mean 0.010836\n",
- "std 3.324879\n",
- "min -9.106664\n",
- "25% -1.509686\n",
- "50% 0.000000\n",
- "75% 1.709475\n",
- "max 4.706846\n",
- "Name: WOE, dtype: float64\n",
- "DEBUG - base_odds: 3.4494161849370615\n",
- "DEBUG - factor: 72.13475204444818, offset: 510.6823897079104\n",
- "DEBUG - Initial RawScore stats: count 800.000000\n",
- "mean -0.781668\n",
- "std 239.839295\n",
- "min -339.527143\n",
- "25% -123.312560\n",
- "50% -0.000000\n",
- "75% 108.900823\n",
- "max 656.906944\n",
- "Name: RawScore, dtype: float64\n",
- "DEBUG - n_trees: 100\n",
- "DEBUG - Normalized RawScore stats: count 800.000000\n",
- "mean -0.007817\n",
- "std 2.398393\n",
- "min -3.395271\n",
- "25% -1.233126\n",
- "50% -0.000000\n",
- "75% 1.089008\n",
- "max 6.569069\n",
- "Name: RawScore, dtype: float64\n",
- "DEBUG - tree_max: [2.4009146166012805, 6.569069439190969, 2.0868990052947596, 2.1229738981726767, 2.3363019337804802, 1.941374614293064, 1.9056139656024051, 2.1771851281156698, 1.3561089626868221, 2.257417464212293, 2.1229738981726767, 1.908320713695587, 2.0699253393330905, 2.2637174826021096, 2.198095215962983, 1.8873510275898482, 1.8670492285382025, 2.3381491755593506, 2.4553818023778624, 1.7659227656417664, 2.346586084841549, 2.379380255655337, 6.569069439190969, 2.4643427563764932, 1.7316523138922517, 1.912937353584606, 1.8793802556553372, 2.316614814613408, 6.569069439190969, 6.569069439190969, 1.6366668420702164, 6.569069439190969, 6.569069439190969, 6.569069439190969, 2.30718530098775, 1.5868990052947591, 2.1134546735245405, 2.0868990052947596, 6.569069439190969, 1.4259349578510778, 6.569069439190969, 1.5649273315009609, 6.569069439190969, 2.6718615060159157, 6.569069439190969, 2.586899005294759, 2.586899005294759, 6.569069439190969, 0.057684891055666807, 6.569069439190969, 6.569069439190969, 2.0057632908453207, 1.5868990052947591, 2.0868990052947596, 6.569069439190969, 2.5561987329626876, 1.0868990052947591, 1.928028856332795, 6.569069439190969, 6.569069439190969, 6.569069439190969, 2.586899005294759, 0.3771295595378671, 2.586899005294759, 6.569069439190969, 1.5868990052947591, 6.569069439190969, 6.569069439190969, 0.5868990052947588, 0.5868990052947588, 1.5868990052947591, 6.569069439190969, 1.5868990052947591, 6.569069439190969, 2.0868990052947596, 1.5868990052947591, 2.0868990052947596, 6.569069439190969, 2.369291314686522, 1.5868990052947591, 6.569069439190969, 6.569069439190969, 0.5868990052947588, 6.569069439190969, 6.569069439190969, 6.569069439190969, 6.569069439190969, 6.569069439190969, 1.5868990052947591, 6.569069439190969, 6.569069439190969, 6.569069439190969, 1.5868990052947591, 6.569069439190969, 6.569069439190969, 6.569069439190969, 6.569069439190969, 6.569069439190969, 2.586899005294759, 6.569069439190969]\n",
- "DEBUG - mean_shift: -1.340011366935637\n",
- "DEBUG - Points stats before rounding: count 800.000000\n",
- "mean 5.114641\n",
- "std 2.943533\n",
- "min 1.340011\n",
- "25% 2.926910\n",
- "50% 4.540022\n",
- "75% 7.531875\n",
- "max 11.304352\n",
- "Name: Points, dtype: float64\n",
- "DEBUG - Points stats after rounding: count 800.00000\n",
- "mean 5.05500\n",
- "std 2.96909\n",
- "min 1.00000\n",
- "25% 3.00000\n",
- "50% 5.00000\n",
- "75% 8.00000\n",
- "max 11.00000\n",
- "Name: Points, dtype: float64\n"
- ]
}
],
"source": [
@@ -365,7 +311,7 @@
"\n",
"# Prepare X and y\n",
"X = credit_data[features]\n",
- "y = credit_data[\"Final_Decision\"].replace({\"Accept\": 1, \"Decline\": 0})\n",
+ "y = credit_data[\"Final_Decision\"].replace({\"Accept\": 0, \"Decline\": 1})\n",
"\n",
"# 2. Create CatBoost Pool\n",
"pool = Pool(\n",
@@ -395,7 +341,7 @@
"\n",
"# Print raw leaf values\n",
"print(\"\\nRaw Leaf Values:\")\n",
- "display(scorecard[[\"Tree\", \"LeafIndex\", \"LeafValue\", \"WOE\"]].head(10))\n",
+ "display(scorecard[[\"Tree\", \"LeafIndex\", \"XAddEvidence\", \"WOE\"]].head(10))\n",
"\n",
"# sc = constructor.create_points(pdo=50, target_points=600, target_odds=19, precision_points=0)\n",
"\n",
@@ -412,7 +358,67 @@
"outputs": [
{
"data": {
- "image/png": "iVBORw0KGgoAAAANSUhEUgAABoUAAAUeCAYAAAC49jhYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAk6QAAJOkBUCTn+AAA23VJREFUeJzs3Qe0VOX5B+qPJgoIigWwgF0jltixIxZssURjV+w1URNbNH9bjLHHxF5i1xh77wXsXWMBOyAqig1FQRHh3PXue2funMMpcw6n7+dZa9aZsmfPnj17Nvr95n2/DhUVFRUJAAAAAACAdq1jS28AAAAAAAAATU8oBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADnRu6Q0AAKB9mzRpUnr11VfT6NGj09dff52mTp2aevbsmeaee+60+OKLp5VWWinNMcccKe/23HPPdM011xRvV1RUNMqyrcU333yTXnzxxfTxxx+niRMnpl9++SV179499e7dOy266KJpqaWWSvPPP39LbyZtzNVXX5322muv4u3hw4enwYMHp7wYO3Zs9v0pOPHEE9NJJ500y8u2d3k/bgCAfBMKAQDQ6CZPnpwNut1www3phRdeSDNmzKhx2c6dO6dVV1017bbbbmnnnXfOQgLahwh+rr/++nTxxRdngVBd+vfvn9Zaa6202WabZZf55puvWbaTWdehQ4daH+/UqVPq2rVrmnPOOVOfPn3SgAED0q9+9as0aNCgtN5666V55pmn2bYVAADyTPs4AAAa1ZVXXpkWWWSR9Pvf/z4999xztQZCheDg+eefz5ZfaKGF0l/+8pf0/fffN9v2tndRCRAD9oVLVAs0h3feeScb8I9f45cTCIVx48al//73v2nYsGHp2GOPbfJtpPlMnz49TZkyJU2YMCG98cYb6Z577klnnnlm+u1vf5v69u2bNt1003T33Xe3iaq3UhF+l36/RowY0dKblAtxHivd73mteAIAaAiVQgAANIoff/wx7bHHHunWW2+d6bGOHTumgQMHZoO/8847b9ZS7rPPPkvvv/9+pQAo1vH3v/89vfzyy+mhhx5q5ndAY3n77bfT+uuvn7788stK988222xp2WWXzVpYdevWLTsOCiHBTz/91GLbS8uKYDi+73GJqsHLLrssaysJAAA0PqEQAACzLOYJ2nzzzWf6lfwyyyyTVXzEYxEGVfXzzz9nczncfPPN6dprr80Ghwvro22aNm1aVv1RGghFa7CTTz45axHYq1evap8T807ddttt6aabbsoqhmjbxowZM9N9EQB/++232dxir7zySlZJ+NRTT2XngYIIhNdcc810+eWXp913372ZtxoAANo/oRAAALPsyCOPrBQIRTufqPiJ+2POoJpE5cjQoUOzyzHHHJOOPvrodNdddzXTVrdd0bIqLq3RFVdckbWOK4iWgDHwHy0Fa9KlS5e0xhprZJfTTjst3XHHHVkFEW1XbZ932GabbbK/UTF4wQUXpHPPPTerFCyEwnvuuWd2fthxxx1rXU8sF5c87+e21nKvNcj7cQMA5Js5hQAAmCUR4sSgbmkgdNVVV6U///nPtQZCVS211FLpzjvvTGeffXa9nkfrEnMClfrHP/5RZ0BQqlOnTmn77bdPhxxySBNsHa1Nv3790qmnnprNK7b44osX74+5yPbZZ5+sxSQAANB4hEIAADRYDNz+6U9/qnRfDOYPGzaswes84ogjsjlFaHuiYuHZZ58t3o5Kj6233rpFt4m2YYUVVsiqDaPVYMHkyZNnOr8AAACzxk8wAQBosGjzNXr06OLtBRZYIJ1++umzvN7FFlusQc/75ptv0jPPPJO1pPrqq69Sz549s/ltYrtq89FHH6WXXnopffHFF9mcJ717987anq277rrVzoFTH9EKKwa7Y46VWHdURsT7W2uttbKqmPYk9n/MD1QQ80hFMNScYh9HMFU4BkJ8njG/1a9//es055xz1nud8Z5inXGsx1xJUck2//zzp+WWWy5bZ2OK4+Xpp59OH3/8cfr888+z/Td48OC08sor1/m+49gfP3589r67d++e+vbtm83Ps/DCC6e2IL5z0X6w0Fou3HvvvenNN99Myy+/fKO/3rvvvpv+97//Zfv5hx9+yD7XHj16ZPtrySWX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/vHHH89S+7g4RoYMGVJp+T59+lTccsstM53XP//884rdd999pu/DAw88UPY+i2M/PpO4Hu0rx48fP9Pyzz33XMXCCy9c6TWiXSkAQDmEQgAANMghhxxSaUDquOOOa/ZQKC4DBw7Mwp5y7LTTTpWee/zxx9f5nAgSCgP3cVl11VVrXDZCgtL1R/hQ3cBuqf3337/auSJqU5/5h2qah6mpxGB6bfO1xMD3Ouusk81hEoOqEyZMaJTjLwKijz76qOxtjMCwOhtuuGGl9W677ba1zqUSA/1Vw6navgtVQ6HC3Esvv/xyWdt+ySWXVHruJptsks1fVZsY7C+dpyc+g+oCitYUCg0aNKjS+q655ppqlyt3cP/AAw+sNFfY6NGjy96WH3/8scbHGjo3TW1z5Oyyyy5lz8U1K6FQ4RLBVm1i3qrS+dpi/33wwQctEgo1ZNlZ/dyuu+66SsvON998Fe+++26t6z/iiCMqPad///6Vwrza9lnhcuaZZ9b6Gm+++Walz6Vv377NMocbAND2td6eAQAAtGpV26/16tWr2behQ4cOWauvaJtVl2gJdfPNNxdvRwu5v/71r3U+L1pLnX766cXb0b4pWr5V54ILLqh0O+bpiLlmanPeeedlLcnai+7du2fzOy200EI1tqyKtn+xb6LlUbTvihZZJ5100kytmWoSrQGvuOKK4u055pgj3XHHHal///5lb2O08atq5MiR6bHHHivejm276qqrUqdOnWpcV7Qdu+666yq1Y7vsssuy91muOA6j3V1doi1VtBcr3b5oCRfvvzbx3bz++uuz70uIbYttbM3mmmuuSrejfdysiNZrBdHGMdpFlqs5W6UtsMAC6ZJLLmm29n6DBw/OWmPWZpNNNkmHHnpo8Xa0Rbv00ktTXsQ5utT555+fllpqqVqfE63f4jgrPf7uvPPOsl8z5go76qijal1mueWWS9tvv33x9ueff15sBQgAUBuhEAAADVI610RLhULrr79+WYPpIQYxS+eIOeWUU8p+nX322Sd169atePvee++daZmYw+Wuu+4q3o6AYtiwYXWuO+bxiHkn2pNll102vfrqq2mPPfYoa3B71KhR6eSTT05LLLFEOvzww7M5MmoTIUxp6BLzFv3qV7+a5e2+4YYbKt3+05/+VNZxvdpqq2XzApUGGA8++GBZrxnh1AEHHFDWsjE30+jRo4u3Y9A45sAqR4SbG2ywQa3HcGsOhWI+r8ZS7txSLWH//ffP5qdpLnXNkVZw7LHHZvMa1fRdaa/ee++9SnPTxbltxx13rPN5ESRH0F0qgtlylftvwuabb17p9uuvv172awAA+SUUAgCgURSqEJpT6UB8XWJy9IJFFlmk7DCpUCkQA/8FzzzzzEzLRAXRtGnTirfjF9zl7pOomGmuyoDmMt9882VVXO+880467rjj6vxlfaEC4V//+ldae+21s4ndy/ksw7777tso21y1AmznnXcu+7m77LJLreuqyZAhQ7LKpXJUfd+lVQLlWHfddYvXX3nllTR16tTUWpUGuI1h6aWXLl6PirQLL7wwtUb1Oac1xne0NCisTVRjbrjhhsXb48ePr1R91V5V/R7vtNNOZT83ApvScLPcc0L8AGG99dYra9mqlaitOfAEAFqPzi29AQAAtE1zzz13pdvfffdds29DaXue2kyZMiW99tprxdvRrm3s2LH1eq3SX+9X99yojClVGiKVsy9jm95///3U3iy55JLp1FNPzS4xkByt4+KX9xGivfDCC9VWgLz55ptp6623zgZRo5Kqqnhe6WB1/Hq/MURQUtCvX7+08MILl/3cQYMG1biuxjiGq4aRUWEUU/nU5zgurfT4+eefs8+jPm3UmlPV80ldLfLqEoP55557bvH273//+6yd11577ZW1kqx6PmsJUV0SLcGay8orr1yvMDrOaaUVcHGMl9uysa2q+j1eY4016vV9i338+OOPFwObjz/+uM7zSlRMdu5c3lBN1UrGSZMmlb19AEB+CYUAAGiQqoOo3377bbNvQ/zSvRwx10LMx1IQ88bMymB41fmUCvPclKrvPEExENgeQ6Gq86XssMMO2aUQTMRncfHFF6d77rlnppAtqjmihVupqMb6+uuvi7frmrOpXNGO7ocffqgUZtVHDPRGcFEIucr9xX65x3AorZ6KbZ3VQCeO49YaClU9n9RnP1Vn9dVXTwcffHC66KKLivc9+uij2SWCkWivFxVqUaER1TPzzz9/am5RVVIa3DW1hpyjSn3xxRepvav6Pa7veSEq1AqhUGF9dYVC9WnFWvV4Ka1WBQCoSfvqUQEAQLMO8JdqiQmuo1qioSHOrCgND2oaxC53rpeWnJOppc0222xZlcbdd9+dhUJVq0GqTvBe3WdZde6ZhprVz6/qZ1h1zq1ZPYab6zhuDaICqur5pD5VWzW54IILsoq10vnBCq3qYi6WCIyioiiqxCIYuvXWW7NtaS71ORYaw6yeo1rihwDNrbHP6+WcF9pbK1EAoPXxXxsAADRIQ9tltQS/nm79ttxyy/SPf/yj0n0x98sHH3zQ6uayai/HcXMGHvXx3nvvzTQYv8IKK8zyeuNYifmtRo8enc4444y05pprVtumK0KiESNGZHN9rb/++umzzz6b5dcGAIDWQigEAECDQ6HSAdWY2yQG8Vuj3r17V7od7ctiQHxWLlVVrVip79wOLTEnU2uz9957p+7du1e6r2pLvaqfZWNVK8zq51f1M2yKOWpK33u0N5vVY3jw4MGpNSpttxWWWmqpmT73WdGnT5909NFHZ3NWReVGtDA86aSTsgCoakj01FNPpU033TRNnTo1tTezeo5qrCq90jCutWns83prmLsKAEAoBABAg8Tg/VprrVXpvquuuiq1RlXnB/nqq68a/TVioLnUhx9+WK/n11URk5d2clXnCKrabinm0CgNCN55551Gee3ZZ5+9Uvuu+n4eMd9PYT6hxpgDp67jOFrJtcZB9MYQc0yV2mKLLZrsteIzHzJkSDrxxBOz6qCoCvrb3/5WqZXhG2+8ka688srU3szqOaqmeZdKg7Vffvml7PW3xnZ0Vb/H9T0vRNVbbesDAGgJQiEAABrs97//faXb//73v9PkyZNTaxO/zo5qg9JWd9OnT2/U11h55ZUr3X7ppZfKfm4EH/UdoC1XW2uvVnU+jerm8Ii2X6UB39tvv90or73KKqsUr48fPz4Lesr1/PPP17iupmjZGIPtr776ampv7rzzzvTmm2/OVEHWXOadd970l7/8JV1++eWV7o85r9rD96tUnAfrEyxWPafVdIyXfmfrE/SMGjWq7GWba79XfY8vvPBC2c+N72hpW9UIhBpjbiwAgFklFAIAoMF++9vfpkUWWaR4+9NPP83m7JhVMedHY9too40qtfR56KGHGnX9q666albFUlCfSepvueWWJqv66Nq1a6XbP//8c2qtIqirGo7169dvpuU22GCDmcLIxlC18u2mm24q+7n/+c9/agyumuIYDjfffHNqTz7++OO07777zjTX1HLLLdfs27LjjjtW+u5Ee8y2/v2qKgLV4cOHl7Xs119/nbXZK1hggQVS//79q122tBomvs/lVgvV55zcXPt9Vs4J999/f6VQrCnOCQAADSEUAgCgwTp16pT+8Y9/VLrvggsuSNdff32D1xnr23///VNji3WW/rr8//7v/xp1npBop7f11lsXb48bNy5dc801dT4vtuHMM89MTaVXr16Vbn/++eepKb3++uuzVCUSbdFKK7xWXHHFmZbbfffds3ZvBZdccslMbZoaYpdddql0+9xzz00//PBDnc+Lip277rqreHueeeZJm222WWpsv/nNbyqFZBdddFEaM2ZMag+iRVuEfRE+lH6nqp5fmku0QCud3ypaG7aG71dji1Z55TjttNPStGnTird33XXXGpct/c7+9NNPWVu+cn4IUJ+Qs7n2e1SYllYLvfXWW+mOO+6o83kR8p988smV7tttt92aZBsBAOpLKAQAwCzZdttt00EHHVRpMGyPPfZIZ599dr1atMWg/jbbbJOOOOKIes1DUa4YqIz1F7z22mtZuFA6D0xdovLn3nvvTV988UW1jx9yyCGVbh955JHp3XffrXWdhx12WJO1jgtLL710pdvlVgY01EorrZRVWcTgaX3E3EBV2xFGJVrp/CSlc5mUVpRMmTIl+2zLbfcWLQ6rC3uiIqW0Cikq3/bbb79aq7gixIjB3tJl4jmloVVjiXX++c9/rvQ+opImAsj6+N///pdefvnl1BrEHD4R0EYVRen3INoIXnHFFWnJJZdstHmKvvzyy7KXf+CBByoFlFW/Ry31/WpsEdjEubo2jzzySDrvvPOKt+M7WVtwX7WSL4Kn2r5DkyZNSjvttFO9qn1izqfSVmxPPvlko7cELTj00EMr3T744IPrrGY99thjK7V3jG2NfysBAFoDoRAAALMsfs2/7rrrVgpPjjrqqLTCCitkVUOlv/4vFYOADz/8cNpnn33SwIEDK1VbNIWoKCkdSIy2bdH2LX6hXtOAZAxmRhVD/Op7mWWWyao1SgeLSw0ePDgLRArifa+//vrptttum2lQNAbDYyD00ksvzW7PNddcqSmsttpq2QBqwRlnnJFOPfXUbA6cGISPtliFS2NM9B6ffezP5ZdfPhvoj31eW+gVAdvpp5+eVl999Uq/9o8qjaq/tK9auVA6IB/zCsVnedlll2UhUXUiqIr5YgYMGFDjhPFR6VYa6Pz3v//NgpfqBoFjIHrttdeuNKfRYostlr1GU4ngrLQKKeZhiSDunHPOyeamqkkERxdeeGF2PMbyTR0KlR5XhUvs/6effjr7np9wwglp4403ztpPxvFY+plFa7Crr7660ndpVsVxH+3OosIlKtK+//77apeLQPraa69NO++8c1lVHvF5L7TQQsXb8dw49z311FPZMVb6/qNdW2tSOOccffTR6Y9//GPWVrNUnBPjmInAtbRKKMLuJZZYosb1brLJJmnBBRcs3n7iiSeyAL664/Pxxx/PWrTFfEX1PQeut956xetxjtl+++2zlm0RxJfu9/rMDVad+Ozje1MQ56n49y4qhqq2CI3z2V577TVT9WecB6sLuAEAWoL/KgEAYJbFIHr8sj4G/kpb68SAddwXv/qPKoy+fftmE7nHL8MjFInqoOoGZ7t169Yk2xkVJjEgHYPqEyZMKG5jDD5HcBKD5bGNcT22MZYZOXJkVpFRrvPPPz8bcC8EIbGOGKyMQdJYf48ePbIB+piwvPDL9hgcj7ZgMaDc2Oacc840bNiwbFAyRGVUVGbEpaoTTzwxnXTSSY322hE8xSX06dMna8UUn398vrF/Y9A8joGqv/CPVl0RxpQOLFcV+/H222/P9t348eOL+/qAAw7IftkfYVjs02hxGBUiEeyVUymy7LLLZgPhUYlUGPCNYzsGwVdeeeUsBIgB8gg4qgZLPXv2TDfeeGO2bU0lvksxf9HQoUPTiy++mN0XIWUM1MfgfoRxEXrFtsRxG8FkHOPNHUgsuuiiDXpeBHsRlMa+bmzRyiz2XVyilWQcj7Gd0aYwxDkpKgirhiMRikQYXFtQV6jgivA3Km+qq76J72GEXa1FfFei8jHOcf/85z+zaqoIaOIcGAFOnKOqBjmDBg3KzhO1ie9chCKlLeZin8e/DbH+OBfEeT/2dSGwiXNufO71CQKjMjPWW/ieRtgXl6ri+1DTnFDlfufi3LzOOutk816FOOdEJWPpeT0ei31WtdL1mGOOSZtvvnmDXx8AoLEJhQAAaBRR2RGD9FGpcdxxx1WqDipU28SlrnXEr+zj0lQKVRIx+Pjss88W74+wpPR2bQFYba3BYpL1+PX7RhttlN5///1KrcjiUlVUyMTk5fFL/aZy1llnZcFAVLY0tRhor25+nwhsCkFcbSJ8ufzyy7Oqq3ICnBiEjbZMpZUvMU9TVKQ01N57750FV/G30F4wBp5feeWV7FKdGBy+5557suOrqUVFRXyWEUZEi7XCoHh8z2JOp7rmdYpApOqcLC0pKiiGDBmSteXaaqutKs391VRin0VFSV3tHeM8UdfcYBHIxXERlYdtSZzH7rvvvuxcFQFnfG9qa38XgVAEpOW0Roz5uWKflM4JFd+lxx57rNrg+tZbb83OHfURlYgRvkUY2lSt4wqiyuyZZ55JW2yxRXrzzTfrPK8XwrFTTjklayUHANCaaB8HAECjirkm4lfZMQfFGmusUecAb5cuXbJfj8ev1ONX4/Er9KaqFCqIdk8xwHf33Xdn4UNsQ21iezbddNN00UUXZdUE0fKqrgHEmLclwq2o2KhOVLFEVU4M7hcqFZpK/Io9BnsjtIvB2ghTIhRoinZGMcge4V8Mhm644YZZ0FfOL/GjDVtUCsSAazmBUOlnGRUzN9xwQzYhfG3HW1QgRWVRVPNE5VptorVfhHrR2jAGrWsSn2Mcs/G+myMQKm2xFuFZHGfR6qym46x0gDq+j9GSL6rYqrZHa2rxGUc1SFTrxb6Plnzx/YjWitGO66GHHkpbb711kwVCUSEYn1MEG3V932Nb49iNwCQq1mJf17Vvo2VizL0TQWLMXxbf6bpepzWIKpoIVA8//PAaz0PxmUWLx2iJV58Wb9HS8LrrrsvOhzV9H+N7FsdwtJxriD/96U/Z+SaOpfh3JEL5uj6vhorWozFPULSYXHzxxWtcLl4/qssinBUIAQCtUYeKqk1wAQCgEUUrphhIizlZooVVtN6KQfbevXtnA2vRJqqcX543pWizFVVC0f4nKpwK2xhtlGIeoZi7JgYwG9qyKgKZMWPGZC3TYp3RsipaEcVgcnsXrZQihIjqofhFfRwPcV8EVTHAvOSSS2aD6OWER+WIaqT4LONvfJbxucWxFp9hhDYNeZ04HiJEjGM4WtBFmBYD5TEPVqyzOSpb6hL7NAb3I8iK9x3HdLzXwnuPILC2cCtP4jtZaP8XgdQPP/yQBTgRlBbaBMZ+y5uoFIqQ+qOPPsqO83nmmSf7fsbcPbNyripU2UW7uPg3II7DCFjiHBiv0VZFEBz/tsW5Jqqg4r1EABbzDTXW+QwAoCkIhQAAAAAAAHJA+zgAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADnVt6A8ifvn37pm+//TZ16dIl9e/fv6U3BwAAAAAAWoVx48aladOmpbnmmit9/vnnjb7+DhUVFRWNvlaoxeyzz56mTp3a0psBAAAAAACtUteuXdNPP/3U6OtVKUSziwqhCIXioF588cVbenMAAAAAAKBV+PDDD7Px8xhHbwpCIZpdtIwbNWpUFgiNHDmypTcHAAAAAABahYEDB2bj50019UrHJlkrAAAAAAAArYpQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADnQuaU3ABrLjBkz0vTp07O/ALQtHTt2TJ06dcr+AgAAANA0hEK0aREC/fDDD2nSpElp8uTJqaKioqU3CYAG6tChQ+revXvq2bNn6tGjRxYSAQAAANB4hEK0WT/++GMaN26cyiCAdiKC/Qj64xIVQ/37909zzDFHS28WAAAAQLuhRwttkkAIoH2L83uc5+N8DwAAAEDjUClEm2wZVzUQ6tq1a9ZuqFu3btmvy6MFEQBtp0IozulTpkzJ2oFOnTq1UjC0xBJLaCUHAAAA0AiEQrQ50VaoNBCae+65U58+fQRBAG1cBPvzzDNPmjBhQpo4cWJ2X5zv47zfq1evlt48AAAAgDZP+zjanPgVeWmFkEAIoP2I83mc1+P8Xt15HwAAAICGEwrRpsQvxidPnly8HS3jBEIA7Uuc1+P8XhDnfXPIAQAAAMw6oRBtbj6hmHuitNUQAO1P6fk9zvtx/gcAAABg1giFaFOq/lK8Y0eHMEB7VPX8rlIIAAAAYNYZUadN0zoOoH1yfgcAAABofEIhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAbd6ee+6ZOnTokF2uvvrqWV7fIossUlzf2LFjG2UbAQAAAKClCYWAFrfbbrsVB+DjcsYZZ7T0JgEAAAAAtDudW3oDoDmddFJLb0Hr0Jr2w/fff5/uuOOOSvddc8016ZhjjmmxbQIAAAAAaI9UCgEt6pZbbklTpkypdN/bb7+dXnrppRbbJgAAAACA9kgoVKavvvoq3XXXXekvf/lL2mqrrdLAgQPT3HPPnbp06ZK6deuWFlxwwTR06NB02mmnpU8//bTe63/sscfSHnvskZZaaqnUvXv31Lt377TCCiuko446Kr3zzjsN2uYYWI/nx3pifbHeWP+wYcOy14PWIKqCCuaYY45q7wcAAAAAYNZpH1ePSczvu+++ah/75Zdf0o8//pjGjx+fHn744XTyySenY489Nh1//PGpY8fac7dJkyal/fffP910002V7o/KiYkTJ6Y333wz/etf/yqus1ynnnpq9pxp06ZVuv/999/PLtdee23aeeed06WXXprmnHPOstcLjWnMmDHpqaeeyq7HXEJnn312OuSQQ7LbN954Y/rHP/6RZpttthbeSgAAAACA9kEo1ADzzjtv+tWvfpUGDBiQevTokQU4H3zwQXrxxRezgGjq1KnppJNOSqNHj6612iECm2233TY9/vjjxfuWW265tPLKK6effvopGyz/7LPPsuWOO+647O8JJ5xQ5/bFMqecckrxdr9+/dK6666bZp999vTKK6+kkSNHFgfdv/766yzs6tzZoUDzi3CyoqIiu77++utnAWl8d7788sv0zTffpHvvvTf99re/benNBAAAAABoF7SPK9PgwYPTJZdcklXZxID1k08+ma677rp08cUXZ8HPM888kz755JOs+qZ0wPvWW2+tcZ0R3BQCoQhsIqSJyqBYX1QOjR07Nmv/VhCD5U888USt2xlt4UoDoXh+rCfWF+t966230n/+85/s9UJUNv3973+fpX0DDRFhUHxHCnbfffcsnNxpp53q3UIujvGoNIrLIossUun7EN/JxRdfPGtNN99882UB6QUXXJCFt3WJdRXWG68Rop3j4YcfnpZddtnUs2fP7BItGv/v//4vff7552VVHRbWefXVV2f3ffvtt1lF4HrrrZe1ooz9EI/H/dW1sjz99NOzEC0C365du2ZB9UorrZR930eNGlXja0ewHMsWXv+5555L5dpkk02KzzvrrLNqXTbmg/rjH/+Yfv3rX2f7PKq9+vbtm23zGWeckVVB1sedd96Ztt5662zfxPtdaKGF0sYbb5ydgyOIbw0++uijLJAfNGhQ6tOnT/ae42/cPvHEE9PHH39c9roivI9WpFtuuWVabLHFsh8fFNa3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TMsss0yluX2q8/TTT2fHemsTIdE555xTKQiK1o8AAAAAtA5CoSYQc2CstdZaxdvRVqdUtGIq+OKLL2aaD6g6pb9Ej/Cmaiue+eefP/t1ecFHH31U5zrjdUsnS4+BSGgqpVU+m222WTbYX87lxRdfTMstt1zxuddee22dr3XdddfVuUzpejbYYIM6l48qp6pVf1VFqFJYpl+/fpW+6w0V85MVvPbaa+mNN96odflot/bf//632udXp7QKqBAK3XbbbcUqwjiXLbroorW20/zVr35VrAaLlmGzqvTziJaB0YqsNrFM1RabTa10v95///3Zubw2EfqU/kCg6ufSseP//89xOS3zLr744tSaRSvB+v5IAQAAAICmJxRqInPPPXfxemFejoIYKC4MAMYvvePX9XV59dVXi9cLA7BVld4fg8f1WWe0+olfeENTiPCxdEC8tnZk1SldPgKfuiokovVZbdVCMedXBB8F++67b53bEG2wzj333Bofj4Hvv/71r8XbMd9KVNnMqghr11tvveLt3//+97WGJP/3f/9XDCiiYnGXXXapdf0RTiy44ILZ9TFjxmTVLIVwKOy+++51buMxxxxT6fXffPPNVK7qWs7FNke4XmirdsYZZ9T4/Di/1jX3TlPYZJNNimFZBFKHH354jcvG8fqHP/yh+LktvvjiaaONNqq0TKyrcLxE1VNhbp6aQrCovGsJ0Xaxvu384kcLAAAAALQOnVt6A9qrzz77rMa2bLPPPnsaNGhQsZVQTMQdt2sTE3nX9cv/+HV9oSVWrPPPf/5z2euMaoCuXbvWujw01H/+85/igHjMYfOb3/ymXs/feeedszlWYnA9quYi1KnpexBtt+K1ttxyy6y6p+rg+3333Ze1QysESxtvvHHacMMN69yG2WabLQs/Yv0xwF9a2RFtInfcccdiGBPt6P74xz+mxmxJGcFQVCE99dRTabvttkv//ve/Kw22R5XOiSeeWCm4itt1zRUW7yP279lnn53dPuuss9Ljjz9efM877LBDWaFdVF7F86L13DrrrJPOPPPMtNdee2XrqG4+nGhPd/nll2dVj7fcckulx3v16pWOPvrodNJJJ2W3I/SJ/X7kkUcW56oJMQdP7PeowonXiX3QXGK/nX766dnrhzjWunfvnu3/0n0e+yOOl9tvv714X+yb0uOnMMdQ/DsQ5/AZM2ak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B4RCAM1kxIgRqUOHDtll8ODBNS5XWCYuedOY773c/Q0AAAAAedG5pTcAyI8YmH/iiSeqfaxr166pV69eqWfPnqlPnz5ppZVWSqusskoaMmRI6t+/f7NvKwAAAABAeyMUIl9OOqmlt6B1aIX7YerUqemLL77ILh988EF65plnsvs7duyYNt1003TooYemoUOHtvRmAgAAAAC0WUIhoEWsttpqafXVVy/enjFjRvruu+/St99+m0aOHJk++uij4v33339/dtlzzz3Teeedl+acc84W3HIAAAAAgLZJKAS0iM033zydVEvF0ueff56uu+66LAT65JNPsvuuvvrqLDCKFnRzzDFHaq8qKipaehMAAAAAgHaoY0tvAEB1+vbtm4466qj09ttvp9/97nfF+1966aWsYggAAAAAgPoRCgGtWo8ePdJNN92Utthii+J9N998c3ryySdbdLsAAAAAANoaoRDQ6nXo0CFde+21leYSOvXUU8t6blQaHXfccdn8RX369EmzzTZbmm+++dIaa6yRTjjhhDR+/Ph6bcv06dOzUGqPPfZISy+9dJp77rlTly5d0jzzzJOt87DDDkuPPfbYLLWAi/dbuNRkkUUWKS4zduzY7L5os3f88cenFVdcMc0111ype/fuaZlllkl/+MMfinM0lWvatGlZ+74ddtghLbbYYtm+j/Utuuiiaeedd0533HFHvd5jzBd12mmnZXNJxT6LsC/233777ZdeeeWV1Fo89NBDae+9905LLbVU6tmzZ9amcMCAAWnbbbfN2hfGfinHjz/+mO6888506KGHpnXWWad47MX7js8u1nfFFVekn3/+uc51jRgxovhZDx48uHj/448/nnbaaafs85l99tmzY3C99dZLF1xwQdnb+c4776Sjjz46DRo0KM0777zZNsa65p9//rTKKqukvfbaK11zzTVp4sSJZa0PAAAAgNbNnEJAm9C7d++sbdz555+f3X7kkUfSN998k91fnalTp2YBzb///e8syCn11VdfZZcXX3wxnX322enMM89Mv//97+vchqeeeirtu+++6b333pvpsdiWWF9cYh6kY445Jp1++umpuUQAEfsnwpdS7777bnaJAOKWW26pVHFVWwgR7/PDDz+c6bEIoOLy3//+NwsSbr311rTgggvWur6nn3467bjjjjMFcLEf43LllVemE088MQvp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5hIqKCdwqcus7rvSlmghgoaCcitPYp6i5lS6jfV57dJlqwtydtlll2IgFFVSf/zjH7M2hKNHj85eM/ZVVFnFpbR9XaGyqqk/6xBtEGMeqZjrqTRoiuM/2v7F3FwRCg0YMKDS3FAAAAAAtG0qhYA2IwarS8OHaIFV02D9CiuskA16z6oY1K8uRGhvSvfd7bffnrUSmxWlAVrMq1OOQuu/5lK6jfV57dJlS4+PEHMiFeZFivVHi7ba5qEqt7VcU+jTp08677zz0llnnZVt51NPPZVte8yrVWg7Fy329ttvv6wdYCwLAAAAQNumUghoM2655Zbi9Y4dO6Z11llnpkHugs8//7xRXrN0nWPGjEntVWPvu9JWbFHdVTr/UE2qzkvU1Eq38ccffyxrTp8wduzY4vV555230mOPPfZY8fqwYcNqDYRCOXM3NbWuXbum9ddfP/3f//1fuv/++7P9EC0ES79f559/fqW5twAAAABom4RCQJsQcwRdc801xdubbrrpTPOrxFwoMcAdvvjii/TBBx/M8uuWViM9/vjjqb1aY401itejUmRWRaVWBHchqk5GjRpV53Oee+651JwWXHDBNP/88xdvFyp8ahOByXvvvVe8vfLKK9c4N9Pyyy9f5/qefPLJ1NrEnETx/Xr00UfTcsstV7z/nnvuadHtAgAAAGDWCYWAVi+qTKLqorR9W1Q1VDXHHHOkIUOGFG9fdNFFs/zam222WaUqkLfffju1R1tuuWWl9nETJkyYpfVFW7VVV121ePu6666r8znXXnttam4bbLBB8frVV19d5/KxTGHunwUWWCAtvfTSlR4vBGHltM2LAOmuu+5KrVUErJtssknx9qweEwAAAAC0PKEQ0KpFELTTTjul++67r3jf7rvvntZcc81qlz/mmGMqtbyKaodyVdc2bfXVV09rr712MZzaY4892uXcQvE+Bw8eXGylFvv4559/Luu5sdzEiRNnun/fffctXo/5aEorbKr673//m55++unU3A444IDi9TvuuCM99NBDtbZ6O/XUUys9t0OHDpWWWWyxxYrX77777hrXFXNj7b///mXv48YUn1Uh2KpPS7/SqioAAAAA2iahENAqRUBz9tlnZ3Oy3HzzzcX711prrXT55ZfX+LyYGyWqisIvv/yStthii3TaaafVGOT89NNP6c4770xbb7112mqrrapd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SyyxRBa2RPu4BRdcsNJjM2bMSFdffXX6wx/+kKZMmZLGjx+fdt111ywsitZvdbngggsaLcCKqqaC2IZLL700de/evXjf448/nrWQi+qmN954Ix166KFZkAQAAAAAALRf5hQqQ79+/dJVV12V3nnnnXTMMcfMFAiFjh07pr333jtdf/31xfuef/759PDDDzfrth5//PFp2rRp2fW11147XXvttZUCoRBVQzfccEPxdizz9ttvN+t2AgAAAAAAzUsoVIb1118/7bnnnmW1b9t2223T6quvXrx93333peYyYcKEbK6ggjPPPDMLq6qz2WabZXMKhenTp6dLLrmk2bYTAAAAAABofkKhJhAVOgVjx45ttte9++67szZ2hTmP1lprrVqXj6Cr4M4772zy7QMAAAAAAFqOUKgJlM4hFFU4zWX48OHF64MHD65z+Q022KB4fdy4cemDDz5osm0DAAAAAABaVucWfv126c033yxeX3jhhct6zpNPPplefPHFrAVctKmbd95504orrphV+1SdE6gmpfMCrbzyynUuv8ACC6Q+ffpkr1l4/hJLLFHWawEAAAAAAG2LUKiRRcXN448/XrxdmLennHmLqtOtW7e09957p+OPPz7NP//8ta7j3XffLV4fMGBAWa/bv3//Yij0zjvvpN/85jdlPQ8AAAAAAGhbhEKN7E9/+lOxZVwELrMaskyZMiVdcMEF6bbbbku33357GjRoULXL/fjjj9mlICqAytG3b9/i9W+++abB23nhhRemiy66qKxlP/zwwwa/DgAAAAAA0DBCoUZ0zTXXZOFNwWmnnZa6du1a4/Lx2NZbb50233zztOqqq2Yh0uyzz56FMy+//HK6+uqrs/VVVFSkzz77LG2xxRbpueeeS0sttdRM6/rhhx8q3Z5jjjnK2ubS5aquoz6+/PLLNGrUqAY/HwAAAAAAaFpCoUYSIc6BBx5YvL3zzjunXXbZpdbnfPrpp2meeeaZ6f6o8okAKC733ntv+t3vfpd++umnLCw6+OCD06OPPjrTc+LxUrPNNltZ210aWpVWGtXXfPPNl5ZddtmyK4WmTp3a4NcCAAAAAADqTyjUCMaMGZO1iSsEMyussEK65JJL6nxedYFQVVtuuWU677zz0v7775/dfuyxx9Irr7ySVllllUrLRYVRqZ9//rmsbS8NZ8qtLqrOIYcckl3KMXDgQFVFAAAAAADQzDo29wu2N9HWbeONN06ff/55dnuxxRZLDz74YOrZs2ejvcY+++yTtZYreOCBB2ZapkePHpVul1v1U7pc1XUAAAAAAADth1BoFnz99ddZIBTt0EK/fv2y1m7xtzF17NgxDRkypHj77bffnmmZqPIprfSZMGFCWesuhFmhd+/es7ytAAAAAABA6yQUaqBJkyaloUOHppEjR2a355133iwQWnTRRZvk9UqDpq+++qraZZZeeuni9Y8++qis9Y4bN654fZlllpmlbQQAAAAAAFovoVADTJ48OW2++ebZ3D6hV69eWcu4ZZddtklfs6B79+7VLvOrX/2qeP21116rc53jx4+vVFFU+nwAAAAAAKB9EQrV008//ZS22mqr9Mwzz2S3u3Xrlu677760yiqrNOnrloY8CyywQLXLbLDBBsXrI0aMqHOdTzzxRPF6zFm0xBJLzPJ2AgAAAAAArZNQqB6mTZuWtttuu/T4449nt7t27ZruuuuutPbaazfp677zzjvp2WefLd4ePHhwtctFWBXzD4V33303Pf/887Wu9+qrry5e33rrrRttewEAAAAAgNZHKFSm6dOnp1122SXdf//92e3OnTunm2++OW200UYNWt8PP/xQ1nJTpkxJe+65Z/b6hbmLNt1002qX7dOnT/rtb39bvH300UenioqKapd9+OGHs0vo1KlTOvDAAxvwLgAAAAAAgLZCKFSGCFb22WefdOutt2a3oxrnuuuuyypzGmqRRRZJJ5xwQlYFVJNoUbfmmmumF154oXjfKaecknr06FHjc+LxLl26ZNefeuqpNGzYsErzEYXhw4dnAVfBHnvs0aTzIQEAAAAAAC2vQ0VNpSQUXXTRRemQQw4p3l5yySXTJptsUvbzL7jggpnu69ChQ6U5glZYYYWs0mf22WdP33zzTXrllVfS6NGjKz0ntqG6dVV1xRVXpH333bd4e+65505DhgxJPXv2TKNGjaoUMsXrPv3002nOOedMzWXgwIHZdkQQNXLkyGZ7XQAAAACgaZ10UktvQetl39Aaxs87N/oa26Evvvii0u33338/u5SrriBn/Pjx2aUmEeqceeaZlYKe2kRVU2R9hx9+eFYlNHHixHTbbbfNtNyGG26YVTw1ZyAEAAAAAAC0DKFQC3nvvffSc889l11ef/319OWXX6avvvoqm2so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TZ988kn22IgRI9Kaa66ZnnrqqbTYYovVuu799tsvXXvttcXbsfygQYOy688//3waPXp0Fhb985//TJMmTcrCnrpceeWVad999y3enmuuudKQIUNSr1690ltvvVWsPHr00UfT0KFD0zPPPJN69OhRz70CAAAAAAC0JUKhelhiiSWysCXaxy244IKVHpsxY0a6+uqr0x/+8Ic0ZcqUNH78+LTrrrtmYVFU+tQU3hQCoY4dO6ZzzjknHXroodn1wjrPO++8dMQRR2TXY/n1118/7bHHHrUGWFHVVBDbcOmll6bu3bsX73v88cezFnITJ05Mb7zxRvaasW4AAAAAAKD9MqdQGfr165euuuqq9M4776RjjjlmpkAoRJCz9957p+uvv754X1T6PPzww9Wuc+rUqdl8PwVHH310Ovzww4uBUGGdcV+0lys44YQTspZzNTn++OPTtGnTsutrr712FjqVBkIhqoZuuOGG4u1Y5u233y5jTwAAAAAAAG2VUKgMUZ2z5557pk6dOtW57LbbbptWX3314u377ruv2uXuvvvu9PHHH2fXo61bhDk1iSCoZ8+e2fWPPvqoxnVOmDAh3XbbbcXbMQdRachUarPNNsvmFArTp09Pl1xySZ3vDQAAAAAAaLuEQk0gKnQKxo4dW+0yd955Z/H6jjvumLp161bj+uKxHXbYoXj7jjvuqDFoijZzhTmP1lprrVq3M4Ku6rYHAAAAAABof4RCTaB0DqGowqnO8OHDi9cHDx5c5zo32GCDSnMCNfY6x40blz744IM6nwMAAAAAALRNQqEm8OabbxavL7zwwjM9/t1336XPPvuseHvllVeuc52ly3z66adp0qRJMy1TOi9QOetcYIEFUp8+fap9PgAAAAAA0L4IhRpZVNyUVvIU5u0p9e6771a63b9//zrXW3WZquuoet+AAQPK2t7S9b7zzjtlPQcAAAAAAGh7hEKN7E9/+lOxZVwELr/5zW9mWubrr78uXu/Zs2eaY4456lxvzCs055xzFm9/8803lR7/8ccfs0tBaQVQbfr27VvjOgEAAAAAgPajc0tvQHtyzTXXpNtuu614+7TTTktdu3adabkffviheL2cQKh02e+//36mdVR3u9z1li5XdR31ceGFF6aLLrqorGU//PDDBr8OAAAAAADQMEKhRvLyyy+nAw88sHh75513Trvssku1y/7000/F67PNNlvZr1EaMJVWBVVdZ33WW9s66+PLL79Mo0aNavDzAQAAAACApiUUagRjxozJ2sQVgpkVVlghXXLJJTUuP/vssxev//zzz2W/ztSpU2usBCpdZ33WW9s662O++eZLyy67bNmVQqWvCwAAAAAAND2h0Cz67LPP0sYbb5w+//zz7PZiiy2WHnzwwWyuoJr06NGjQdU5pcuWrqO62+Wut7Z11schhxySXcoxcOBAVUUAAAAAANDMOjb3C7YnX3/9dRYIFebI6devX3r00Uezv7WZZ555itcnTZo0U+u36kyZMqU4n1Do3bt3pcejyqe00mfChAllvYdCmFXdOgEAAAAAgPZDKNRAEeYMHTo0jRw5Mrs977zzZoHQoosuWudzl1566Uq3P/roozqfM27cuFrXUfW+ctZZdb3LLLNMWc8BAAAAAADaHqFQA0yePDltvvnm6ZVXXslu9+rVK2sZV+6cOrF8aTXRa6+9VudzXn311eL1BRdcsNr2dL/61a/qtc7x48dXqigqfT4AAAAAANC+CIXqKVq9bbXVVumZZ57Jbnfr1i3dd999aZVVVqnXejbYYIPi9REjRtS5/BNPPFG8PmTIkEZfZ//+/dMSSyxR53MAAAAAAIC2SShUD9OmTUvbbbddevzxx7PbXbt2TXfddVdae+21672ubbbZpnj9pptuSj/++GONy8ZjN998c7XPLRVhVceO/+9H+u6776bnn3++1m24+uqri9e33nrrem0/AAAAAADQtgiFyjR9+vS0yy67pPvvvz+73blz5yyo2WijjRq0vghwFlpooez6t99+m0499dQalz3llFOyZcKAAQPSlltuWe1yffr0Sb/97W+Lt48++uhUUVFR7bIPP/xwdgmdOnVKBx54YIPeBwAAAAAA0DYIhcoQwco+++yTbr311ux2VONcd911WbDTUFFldPLJJxdvn3baaem8885LM2bMKN4X1+O+M844o3jfX//61zTbbLPVGiB16dIlu/7UU0+lYcOGZXMglRo+fHgWcBXsscceZc+HBAAAAAAAtE0dKmoqJaHooosuSoccckjx9pJLLpk22WSTsp9/wQUX1PhYBDIRMBUsvvjiadCgQdn1aP/24YcfFh/ba6+90pVXXlnn611xxRVp3333Ld6ee+65s3mIevbsmUaNGpVeeOGF4mMrrLBCevrpp9Occ86ZmsvAgQOz7YggauTIkc32ugAAAAAwq046qaW3gLbKsUNrGD/v3OhrbIe++OKLSrfff//97NIYodC///3v1KtXr3ThhRdmFUkRApUGQaFDhw7pD3/4Qzr77LPLer2oaop1HX744VmV0MSJE9Ntt90203IbbrhhFkg1ZyAEAAAAAAC0DKFQC4tWcOeff37afffdsyqgESNGpE8//TR7bMEFF0yDBw/OQp7VVlutXuuNSqGoZoqqoXvuuSeNGzcu/fDDD6lfv35p5ZVXTrvttlvaZpttssAJAAAAAABo/7SPo9lpHwcAAABAW6UFGA3l2KE1jJ93bPQ1AgAAAAAA0OoIhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcEAoBAAAAAADkgFAIAAAAAAAgB4RCAAAAAAAAOSAUAgAAAAAAyAGhEAAAAAAAQA4IhQAAAAAAAHJAKAQAAAAAAJADQiEAAAAAAIAcaBWh0L333psqKipaejMAAAAAAADarVYRCm211VZp4YUXTscff3waM2ZMS28OAAAAAABAu9MqQqHw2Wefpb///e9pySWXTBtttFG66aab0s8//9zSmwUAAAAAANAutIpQaMCAAVn7uLjMmDEjDR8+PO2yyy5pgQUWSH/84x/TW2+91dKbCAAAAAAA0Ka1ilAoWsY98sgjaccdd0xdu3YtBkTffPNNOu+889KKK66YBg0alK644oo0efLklt5cAAAAAACANqdVhEJhww03TDfeeGMaP358+te//pUFQaEQEL300ktp//33T/369Uv77rtveu6551p6kwEAAAAAANqMVhMKFcw999zpD3/4Q3rttdfSyy+/nA488MDUq1evYjj0ww8/pKuuuiqts846abnllkv//Oc/09dff93Smw0AAAAAANCqtbpQqNTKK6+cLrroovTZZ5+la6+9Ng0ePDh16NChGBCNGjUqHXHEEWmhhRZKO+20U9aCDgAAAAAAgDYWChXMPvvsabfddkuPP/54ev/999Oxxx6bFlhggeyxCIemTp2abrnllrTpppumxRZbLJ166qnp888/b+nNBgAAAAAAaDXaRChUqhD6PPHEE2mNNdbI7ovqoUJA9NFHH6UTTjghDRgwIO2xxx7pww8/bOEtBgAAAAAAaHltKhT6+eef00033ZQ22WSTtPTSS6cXX3yxUju5rl27Fq9PmzYt3XDDDWn55ZdPl156aUtvOgAAAAAAQItqE6HQG2+8kQ477LCsZdwuu+ySHnvssTRjxows/IlQaPPNN0933nln+v7779OIESOyVnPRci4e/+mnn9LBBx+cHnjggZZ+GwAAAAAAAC2m1YZCkyZNSpdccklabbXV0korrZQuuOCC9M033xQrgRZeeOF04oknprFjx6Z77703bbXVVqlz585pvfXWS9dee20aM2ZM+t3vfpetK5Y/++yzW/otAQAAAAAAtJjOqZWJuYKuuOKKdNttt2VVPoVQJ3Tq1CmrCtp///3TZpttljp2rDnT6tOnT7rxxhvT66+/nt577730yiuvNNt7AAAAAAAAaG1aRSj02WefpauvvjpdeeWVafTo0ZWCoDBgwIC0zz77pL333jtrIVeuCI3WXXfdLBSK1nIAAAAAAAB51SpCoWgFVwiBCn+jFdyWW26ZVQUNHTo0mzuoIXr06NGo2woAAAAAANAWtYpQaMaMGcXriy66aNp3333TXnvtlfr27TvL61599dXTsGHDZnk9AAAAAAAAbVmrCIW6dOmSttpqq6wqaOONN27Ude+8887ZBQAAAAAAIM9aRSj0ySefpPnmm6+lNwMAAAAAAKDd6phaAYEQAAAAAABADkIhAAAAAAAActA+bvr06Wn33XdPP/30U1pyySXTGWecUfZzjznmmPT++++nOeecM11zzTVNup0AAAAAAABtVauoFLr//vvTf//733TXXXelJZZYol7PjeXvvPPOdP3116eHHnqoybYRAAAAAACgLWsVodB9992X/e3SpUvaYYcd6vXcWD6eF+65554m2T4AAAAAAIC2rlWEQi+99FL2d8UVV0y9evWq13Nj+V//+tepoqIivfjii020hQAAAAAAAG1bqwiFRo8enTp06JCWXnrpBj1/qaWWKq4HAAAAAACAVhoKTZ48OfvbvXv3Bj2/R48e2d9JkyY16nYBAAAAAAC0F60iFOrZs2f295tvvmnQ8wvPa2ioBAAAAAAA0N61ilCoX79+szQnUOF5ffr0aeQtAwAAAAAAaB9aRSi09tprZ3/HjRuXHnnkkXo99+GHH04fffRRNifRmmuu2URbCAAAAAAA0La1ilBom222KV4/6KCD0ldffVXW87744ots+erWAwAAAAAAQCsLhTbddNO0yiqrZNfHjBmTVl999TorhuLxQYMGZctHldCKK66Ytt5662baYgAAAAAAgLalc2olrr766rTWWmulH374IY0dOzYLipZccsk0ePDgtNhii6UePXpkj0UINHz48PT+++8XnxuPXXPNNS26/QAAAAAAAK1ZqwmFBg4cmO666660ww47ZO3jKioqsuCnNPwpFY+H3r17p5tvvjktv/zyzbzFAAAAAAAAbUeraB9XEFVBr776atpll11S586ds+Cnpks8vuuuu6b//e9/aciQIS296QAAAAAAAK1aq6kUKlhooYXS9ddfn84888z06KOPpueffz5NmDAhff/992nOOedMffr0yeYS2njjjVO/fv1aenMBAAAAAADahFYXChUssMACaY899sguAAAAAAAAtKP2cQAAAAAAADQNoRAAAAAAAEAOCIUAAAAAAAByoFXOKTRhwoT08ssvp7Fjx6ZJkyaladOmlf3cE044oUm3DQAAAAAAoC1qVaHQiy++mI477rg0YsSIVFFR0aB1CIUAAAAAAABacSj073//Ox100EFpxowZDQ6EOnTo0OjbBQAAAAAA0B60ilBo5MiRWSA0ffr0Yriz2mqrpZVWWinNM888qUuXLi29iQAAAAAAAG1aqwiFzj333CwQijBo4MCB6cYbb8z+AgAAAAAA0I5CoeHDh2d/55hjjvTAAw+kBRdcsKU3CQAAAAAAoF3pmFqBzz77LKsSGjJkiEAIAAAAAACgvYZC3bt3z/4KhAAAAAAAANpxKLToootmf7/++uuW3hQAAAAAAIB2qVWEQtttt12qqKhITz31VJoxY0ZLbw4AAAAAAEC70ypCof333z8tsMAC6Ysvvkjnn39+S28OAAAAAABAu9MqQqG555473XTTTdncQkcddVS64oorWnqTAAAAAAAA2pXOqRV48skns7+nnXZaOvLII7PKoagY+t3vfpeWX3751KtXr9ShQ4ey1rXeeus18dYCAAAAAAC0Pa0iFBo8eHCl0CfmF3rzzTezS33EOn755Zcm2EIAAAAAAIC2rVWEQoUgqLbbAAAAAAAAtPFQKFq+ldseDgAAAAAAgDYaCo0YMaKlNwEAAAAAAKBd69jSGwAAAAAAAEDTEwoBAAAAAADkgFAIAAAAAAAgB1rFnELVmThxYnrmmWfSxx9/nF3/5Zdf0gknnNDSmwUAAAAAANAmtbpQ6IUXXkh//etf00MPPZQqKioqPVY1FJowYULaeuut04wZM9Kaa66Z/vWvfzXz1gIAAAAAALQNrap93Omnn57WXXfd9OCDD2ZBT4RChUt1+vTpk/r27ZtefvnldOmll6avv/662bcZAAAAAACgLWg1odCFF16YjjvuuKxNXIRAyyyzTDrooIPSqquuWuvz9tprr+zvtGnT0v33399MWwsAAAAAANC2tIpQ6LPPPktHH310dn322WdPV111VRo1alQWFK2xxhq1PnfTTTdNXbt2za4PHz68WbYXAAAAAACgrWkVodAll1ySfvzxx9ShQ4d07rnnpmHDhpX93AiElltuuay66M0332zS7QQAAAAAAGirWkUo9NBDD2V/F1hggbT//vvX+/mLL7549vejjz5q9G0DAAAAAABoD1pFKDR69OisSmjttdfO/tbXXHPNlf2dNGlSE2wdAAAAAABA29cqQqHvvvsu+9u7d+8GPX/q1KnZ3y5dujTqdgEAAAAAALQXrSIUmnvuubO/EydObNDzx40bl/2dd955G3W7AAAAAAAA2otWEQoNGDAgVVRUpFdeeaXez508eXJ64YUXsrZzyy67bJNsHwAAAAAAQFvXKkKhjTbaKPv74Ycfpqeeeqpezz3//PPTlClTKq0HAAAAAACAVhgK7b777qlTp07Z9QMOOCB98803ZT3vscceSyeddFJ2vVu3bmmPPfZo0u0EAAAAAABoq1pFKLTMMsukffbZJ2sh9+6776ZBgwale++9N7tdndGjR6cjjzwybb755unnn3/OWscdccQRaZ555mn2bQcAAAAAAGgLOqdW4rzzzktvvfVWevbZZ7M2cltvvXXq2bNn6tKlS3GZlVZaKX3++efpiy++yG4XQqNNNtkknXjiiS227QAAAADQnvx/zXkAaGdaRaVQmG222dLDDz+ctZKLsCcu3333Xfr666+zSqDwxhtvpAkTJhQfD8OGDUt33XVXcRkAAAAAAABacShUmBfommuuSU888URWKdS9e/diAFQaBHXt2jVtttlmacSIEemqq67KAiUAAAAAAADaQPu4Uuuuu252mT59elYdNH78+KxqKEKiPn36pF//+tdp9tlnb+nNBAAAAAAAaDNaZShU0KlTp2weobgAAAAAAADQTtrHAQAAAAAA0DSEQgAAAAAAADkgFAIAAAAAAMiBVjGn0JAhQxplPR06dEiPPfZYo6wLAAAAAACgPWkVodCIESOyQGdWVFRUzPI6AAAAAAAA2qtWEQoVQp36ihCoIc8DAAAAAADIm1YRCg0fPrys5WbMmJG+++679Oabb6abb745jRw5Ms0+++zp7LPPTgMHDmzy7QQAAAAAAGirWkUotP7669dr+W222SYdf/zx6aKLLkqHHXZYOvbYY9MDDzyQ1lprrSbbRgAAAAAAgLasY2rDDj744HT66aen77//Pu24447pm2++aelNAgAAAAAAaJXadCgUDj/88DT//POn8ePHp8suu6ylNwcAAAAAAKBVavOhUKdOndJ6662XKioq0q233trSmwMAAAAAANAqtflQKPTu3Tv7O2bMmJbeFAAAAAAAgFapXYRCn376afb3p59+atLXmT59enrjjTfSFVdckQ466KC06qqrptlmmy116NAhuwwePLjsdY0dO7b4vHIvSyyxRL229+23305HHXVUWmGFFbLgrHv37mmppZZKw4YNS4899lgD9gAAAAAAANBWdU7tIBB6/PHHs9CkX79+TfY6d955Z9p1113TlClTUltw6qmnppNPPjlNmzat0v3vv/9+drn22mvTzjvvnC699NI055z/T3v3AWZVdeiNe4FUQZoUUQEjEcEW4drAEkCNmvZp1AiaiC2a6BdNvHotsYD1syaaaKKioH72xJJrSSyIDcTeaCpSLPQqvc33rH3/c/4zw5Qzw8ycM2e/7/OczN7nrL3OOjN7scz8Zq21Vc7aCQAAAAAA1I8GHQpNnTo1CTZWrVqVhEKDBw+us/dasmRJnQVCMZQ58cQTqyzXqVOnrOq77LLLwpVXXpk5j2HZgQceGFq0aBHefffdMHHixOT5hx56KCxcuDA888wzoUmTBn0rAAAAAAAAVciLJOCKK67Iuuz69euTIOODDz4IEyZMCEVFRcnzW2yxRTj33HNDXevSpUvYe++9M49///vf4ZZbbtmsOuPSbn/5y19qpX1xWbiSgVBcPu6qq65KlrkrFsOgU045JVlu7/nnnw/XXHNNEiQBAAAAAACFKy9CoeHDhyczfaqrOBBq3Lhxsgxa7969Q105/PDDw8yZM0P37t1LPR+DqXxy0UUXZY6HDBkSrr/++k3KxNlVS5cuTfZFim688cZw5plnho4dO9ZrWwEAAAAAgPrTOOSJGPBU9xGDpIMPPjiMGzcunHzyyXXavm222WaTQCjfvP3228mjOCgrLxAqdsYZZ4SddtopOf7222/D/fffX2/tBAAAAAAAUjpT6PLLL8+6bNOmTUObNm3CDjvskCzfFpdz4388+eSTmeNDDjkkdOvWrcKyMVAbNmxYuOSSS5LzJ554Ivz+97+vl3YCAAAAAAD1r8GFQlTs5ZdfzhwPHDiwyvKDBg3KHMfZVmvWrAnNmzevs/YBAAAAAAApD4XSbv369eGFF14I77zzTliwYEFo0aJFsr/PXnvtFfbZZ5+sg5rJkydnjvv161dl+b59+2aON2zYED799NOw++671/BTAAAAAAAA+UwolAe+/vrr8IMf/KDc19q3bx/OPPPMcOGFF4bWrVtXWMe8efPCkiVLMuc9evSo8n1btmwZOnXqFObPn5+cT5kyRSgEAAAAAAAFqnGuG0DlFi9eHK6++upk1lCcyVORhQsXljrPdq+lbbbZJnO8aNGizWgpAAAAAACQz8wUyqGtttoqHH300eHwww9PlnLbbrvtQtOmTZNZP2+++Wa44447wosvvpiUnTp1alJuwoQJyeyespYvX77JLKBslCxXto7quO2228Ltt9+eVdlp06bV+H0AAAAAAIAGHAqdcsopdf4ejRo1CnfffXfIF127dg3ffPNNuUvCbb/99uGYY45JHnfeeWf49a9/HYqKisL06dPDRRddFEaOHLnJNatXry513qxZs6zaUXK/olWrVoWaikvQTZo0qcbXAwAAAAAAKQiFRo8enYQ2dS2fQqEYxpQMZCpy+umnh5kzZ4Zrrrkm872Ky8mVXR6uRYsWpc7Xrl27yXPlWbNmTbVnF5Unzl7aZZddsp4pVPJ9AQAAAACAlIRCUZwJU1IMico+V53XyyvfUMXZQX/84x+TmTwbNmwIL7zwQvjFL35RqkzZGUexbDahUMnZQeXNWsrWWWedlTyyseuuu5pVBAAAAAAAaQyFRo0alXydNWtWMgsmznKJ+vfvnzy6d+8eWrVqFVasWBG+/PLLMH78+OQRxdk2F198cVKmUMWwZt999w1jx45NzidPnrxJma233rrU+dy5c0P79u2rrHvOnDmZ4w4dOtRKewEAAAAAgPyTF6HQsGHDwoQJE8K5554b1q1bF37wgx+EP//5z2GnnXaq8JrPP/88nH322eFf//pXuOWWW8IzzzyTBCeFKu5BVGzBggWbvN65c+fQrl27sGTJkuQ8LjnXu3fvSuuM+xDFvYCKVVUeAAAAAABouBqHPLB48eJw7LHHJoHGkCFDwnPPPVdpIBR997vfTYKgoUOHhkWLFiXXx6+FKs6SKhZnTZWnT58+meP333+/yjrfe++9zPEWW2wRevXqtdntBAAAAAAA8lNehEIjR44MX331VRJ2/O1vf8t6/59YLpaPy6t9/fXX4a677gqFqmTIs+2225ZbZtCgQZnj4qXmKvPKK69kjgcMGJAsxQcAAAAAABSmvAiF/v73vycBz+DBg8NWW21VrWtj+XhdUVFR+Mc//hEK0YsvvpjspVRs4MCB5ZY78sgjS10Tg7bKjB49utxrAQAAAACAwpMXodD06dOTr9tss02Nru/SpUvydcaMGaEhWLt2bfLIRtzz59e//nWpJeL69etXbtm99947eUQbNmwIF154YYX13nnnneHTTz/NBGsnnnhiNT8FAAAAAADQkORFKLR8+fLk6+zZs2t0/Zw5c0rVk++++eab0LNnz3D99deHmTNnllsmznyKeybFkGfatGnJc3E21Y033hgaN674x3bttddmjh944IEkGFq3bl2p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wldffRVeeumlcOutt4YpU6YkZZ9++ulw/PHHhyeeeCI0blx6O6jly5dnjlu2bJnVe5ctV7KO6oqzjCZNmlTj6wEAAAAAgLolFMqhI488Mpx44ombBDxRr169ksepp54afv3rX4dRo0Ylz//zn/8MDz74YPjFL35Rqvzq1aszx82aNcvq/Zs3b17qfNWqVTX8JCEJs3bZZZesZwqtWbOmxu8FAAAAAABUn1Aoh9q1a1dlmRjwjBw5Mnz++efhtddeS5677rrrNgmFSu4htHbt2qzev2wwk+0Mo/KcddZZySMbu+66q1lFAAAAAABQzzadokLeiTOJLr/88sz5J598kiwvV1Lr1q2rPeOnbLmSdQAAAAAAAIVFKNRAHHTQQaFp06aZ88mTJ5d6feutt84cz507N6s658yZU+q8Q4cOm91OAAAAAAAgPwmFGogYCHXs2DFzvmDBglKv77zzzpnjefPmldpjqCKzZs0qFQjFfYEAAAAAAIDCJBRqQFasWJE5btWq1SahUFxmLioqKgoffPBBlfW99957meM+ffrUalsBAAAAAID8IhRqIL744ouwbNmyzPm2225b6vUWLVqE/fbbL3M+duzYKut85ZVXMseDBw+utbYCAAAAAAD5RyjUQNxzzz2Z47Zt24Y999xzkzJHHnlk5nj06NGV1vfll1+Gl156qdxrAQAAAACAwiMUypHly5dnXXbcuHHhpptuypwPGTIkNGnSZJNyw4YNyywrN3Xq1DBy5MgK67zgggvChg0bkuP+/fuHfv36VfMTAAAAAAAADYlQKEf+/ve/h3322Sfcd999YenSpeWWWb16dbj11lvDIYcckhxH7dq1C5dffnm55Tt37hzOPffczPnZZ58dHn300VJl1q1bFy688MLw0EMPZZ679tpra+lTAQAAAAAA+WrT6SbUm7fffjuZ3RNn/fTu3Tt5tG/fPpnB8/XXX4fx48eX2keoZcuW4amnngpdu3atsM5LL700vPHGG2HMmDFh1apV4bjjjgtXXXVVMhMoBkuvvvpqmD17dqb8iBEjwve///06/6wAAAAAAEBuCYXywPr168Mnn3ySPCoSZxXFfYL69OlTaV1NmzYNjz/+eDj99NMzs4Q+/vjj5FG23PDhw8PFF19cS58CAAAAAADIZ0KhHBk6dGjo1atXsl/Qm2++GaZNmxYWLFgQFi5c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",
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " preds \n",
+ " pdo \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " preds \n",
+ " 1.00000 \n",
+ " -0.91776 \n",
+ " \n",
+ " \n",
+ " pdo \n",
+ " -0.91776 \n",
+ " 1.00000 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " preds pdo\n",
+ "preds 1.00000 -0.91776\n",
+ "pdo -0.91776 1.00000"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "pd.DataFrame({\"preds\": model.predict_proba(X)[:, 1], \"pdo\": points_scores}).corr()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "image/png": "iVBORw0KGgoAAAANSUhEUgAABoUAAAUeCAYAAAC49jhYAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjEsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvc2/+5QAAAAlwSFlzAAAk6QAAJOkBUCTn+AAA4E9JREFUeJzs3Qe4FOX5B+yXJgoIYqOoYG9YYseO2Ess0dhi11hjSWzRxBZj7DGxl9j1b+y9F7B3jQXsgKggKhYUFBHOdz3zfbvfnsMpew6nz31f117s7M7Ozs7Ojsn7O8/zdqioqKhIAAAAAAAAtGsdW3oHAAAAAAAAaHpCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAcqBzS+8AAADt26RJk9Jrr72WRo0alSZOnJimTp2aevbsmXr37p0WW2yxtNJKK6U55pgj5d1ee+2Vrr322uJyRUVFo6zbWnz99dfppZdeSp988kn65ptv0i+//JK6d++e5p577rTIIoukJZdcMs0///wtvZu0Mddcc03ae++9i8vDhg1LQ4YMSXkxZsyY7PdTcNJJJ6WTTz55ltdt7/J+3gAA+SYUAgCg0U2ePDkbdLvxxhvTiy++mGbMmFHjup07d06rrrpq2m233dIuu+yShQS0DxH83HDDDemSSy7JAqG6DBgwIK211lpp8803z27zzTdfs+wns65Dhw61Pt+pU6fUtWvXNOecc6Y+ffqkgQMHpmWWWSYNHjw4rbfeemmeeeZptn0FAIA80z4OAIBGddVVV6WFF144/eEPf0jPP/98rYFQITh44YUXsvUXXHDB9Je//CV9//33zba/7V1UAsSAfeEW1QLN4d13380G/OOv8csJhMLYsWPTf//737Tnnnum4447rsn3keYzffr0NGXKlDRhwoT05ptvpnvvvTedddZZ6Te/+U3q27dv2myzzdI999zTJqreSkX4Xfr7Gj58eEvvUi7Edaz0uOe14gkAoCFUCgEA0Ch+/PHHtMcee6Tbbrttpuc6duyYBg0alA3+zjvvvFlLufHjx6cPPvigUgAU2/jHP/6RXnnllfTwww838yegsbzzzjtp/fXXT19++WWlx2ebbba07LLLZi2sunXrlp0HhZDgp59+arH9pWVFMBy/97hF1eDll1+etZUEAAAan1AIAIBZFvMEbbHFFjP9lfzSSy+dVXzEcxEGVfXzzz9ncznccsst6brrrssGhwvbo22aNm1aVv1RGghFa7BTTjklaxHYq1eval8T807dfvvt6eabb84qhmjbRo8ePdNjEQB/++232dxir776alZJ+PTTT2fXgYIIhNdcc810xRVXpN13372Z9xoAANo/oRAAALPsqKOOqhQIRTufqPiJx2POoJpE5cimm26a3Y499th0zDHHpLvvvruZ9rrtipZVcWuNrrzyyqx1XEG0BIyB/2gpWJMuXbqkNdZYI7udfvrp6c4778wqiGi7avu+w7bbbpv9GxWDF154YTrvvPOySsFCKLzXXntl14eddtqp1u3EenHL83Fuay33WoO8nzcAQL6ZUwgAgFkSIU4M6pYGQldffXX685//XGsgVNWSSy6Z7rrrrnTOOefU63W0LjEnUKl//vOfdQYEpTp16pR22GGHdMghhzTB3tHa9OvXL5122mnZvGKLLbZY8fGYi2zffffNWkwCAACNRygEAECDxcDtn/70p0qPxWD+nnvu2eBtHnnkkdmcIrQ9UbHw3HPPFZej0mObbbZp0X2ibVhhhRWyasNoNVgwefLkma4vAADArPEnmAAANFi0+Ro1alRxuX///umMM86Y5e0uuuiiDXrd119/nZ599tmsJdVXX32Vevbsmc1vE/tVm48//ji9/PLL6YsvvsjmPJl77rmztmfrrrtutXPg1Ee0worB7phjJbYdlRHx+dZaa62sKqY9ieMf8wMVxDxSEQw1pzjGEUwVzoEQ32fMb/WrX/0qzTnnnPXeZnym2Gac6zFXUlSyzT///Gm55ZbLttmY4nx55pln0ieffJI+//zz7PgNGTIkrbzyynV+7jj3x40bl33u7t27p759+2bz8yy00EKpLYjfXLQfLLSWC/fdd19666230vLLL9/o7/fee++l//3vf9lx/uGHH7LvtUePHtnxWmKJJbJzJiofW9JHH32U7WOcz5MmTcrOuwjdo+ViY4tzL1o9xvUwzvMI6KKCc5111ml316rGEFVsMTdWtLqM1odxvYtzJ/670a1bt0Z9rxEjRmS/g7guxHka/x1Zb7316vxvGwBAtSoAAKCB1l9//ZjMong78cQTm/T9TjrppErvN3r06OzxkSNHVmy99dYVXbp0qfR83O68885qtzVt2rSKSy65pGLZZZed6TWFW+fOnSu23HLLijfeeKPe+zplypSKY445pqJXr17VbnuBBRaoOO200yqmTp2arb/nnntWer42da0bx6Wmz1TbLY7vrBg/fnyl7XXv3r1i+vTpFU1txowZFbfffnvFmmuuWdGpU6caP1+cH0OGDKm45pprise9Np999lnFfvvtV9GzZ88at9m/f/+KU045pWLy5Mll7euwYcMqvf7qq6/OHv/mm28qDjrooIq55pprpvc4/PDDa9zeww8/nP0Oa/vcv/rVryruvvvuiqZU9T1nxQorrFD254/jV7puHN/a/PzzzxXnnHNOxWKLLVbn7yF+u7/5zW8qHnnkkTo/bzm3+N3Wdg0dOHBg8fF77rmnYo011qh2O3Gu1PRbr+03XNO6kyZNqjjqqKMq5plnnmrfb/75568488wzK3755ZeKcsTnKLw2Pl+5yvksVf+bU86t9Lg29LwpiGMQ/91YfPHFa3y/2WefPTtv4r9L5arpmN1///0Vq622Wo3vtemmm1aMGDGi7PcBAAjaxwEA0CBTpkzJKhNK7b333s2+HzfeeGNaddVV0z333FOpSqWuCoGoPDjooIPSyJEja1zvl19+Sffff39aaaWV0rnnnlv2Po0dOzatuOKK6ayzzkrfffddtet89tln6S9/+UvaYIMNsiqP9iAqCzp27Fip/dewYcOa9D2jMmbttddO22+/fXr++efT9OnTa1w3zo+o2ooJ5mv73sOtt96aVYv85z//ySo0anv/k046KasqefPNNxv0GaISJCqOLrnkkrLPhe+//z5rzbfpppumJ598stbPHduPdXfcccesGqS1O/jggystP/DAA42y3ah+WWONNdJRRx2VVeDUJX67d9xxR7roootSc4m86Ygjjkhbb711evHFF5v8/aLyZLXVVsvmcps4cWK160QF5bHHHptVDNV0PcuD+K3Hf2vivxsffvhhjev99NNP2XkTLRHPPvvsBr9fzMu31VZbZVWsNXn44YfT4MGD01NPPdXg9wEA8kf7OAAAGiQmho/QpGDgwIFp4YUXbtZ9iDZbEUQV9mOBBRbIwphoGxctfaobTHvppZfS5ptvnrU6K7XIIoukQYMGZa+N52K9wjoxd1IMJEeLoL/+9a91DjxvuOGGMw0axr7FwH+0p4r2TLH92G60Jdt5551Tnz59UlsXLa0iQIuWSgUHHnhgNnDZ0JaAtXnnnXfSRhttlA3WluratWtaZZVVshZL0Wop2qlFYBPfTTmuvfbatM8++2TfT6n4bIsttlgWLr399tuVgoUYXI92To899lg2cFyu2LcIAOL1Idrbrb766mm++eZL33zzTdYyqrrXbLzxxlnYUzWUizZz0cYqArl4bbQtLA26YlD/wQcfrBTetTZDhw6dqU1XfOb4XLMStkQryddff73S4/G7i4A4th3HJI5PfK/x+y29vjWXCJL//e9/F5fjmhThZPy2Imx+5ZVXGu29IrzYcssts5C88LuJgCF+N3HuxTUq/i295m+22WZZ0Dv77LOnPPn000+zUCyu3aXiWMV1IX638RuOIK8Q0Mb5c8wxx2Tn1N///vd6vV+sf+aZZ2b347yM33X8Nzbux3Uvrj+lAfFvf/vb7PFolQkAUCcFUwAANES0PittY7P99ts3+XtWbR/Xo0eP7N8ll1yy2hZP33//fcWXX35ZXP7qq6+ytm2l24i2c9W1h4s2QdFiqLT9W8eOHSueeuqpWvdxp512mqn10m233TZTG7VoTbbjjjsW1+vdu3ejtY+L1njRiilu0XqrdN2nn366+FzVW2lbqoY6++yzZ2pxNMccc1QccMABWYumaN/VGKLl1VJLLTXTsY7WTjW1cnvzzTcrjjvuuKxN1uuvv17tOu+88062v6Xb3WijjSref//9mdYdPnx4du6VrhttpX744Yey28cVzuFoURf7XrWtXZyHY8eOrdQqb7PNNqu0jeWWWy5rMxXPVfXkk0/O1CIxfruNrep3PqvmnnvuStt74oknql2v3DZg991330zf02OPPVbtMQvxHd51113Z73mHHXaY6fnCb6bq+X7TTTfV+PsqvRZV1w4t2o4V2gDGdxznYlWffvpp9vtujPZxhVaFHTp0yK4T3377baX141y84IILKrp161bpdX/+858rWqp9XLSojPXiOla6bux/Tcf9k08+maX2cXGODB06tNL6ffr0qbj11ltnuq5//vnnFbvvvvtMv4cHH3yw7GMW5358J3E/2leOGzdupvWff/75ioUWWqjSe0S7UgCAcgiFAABokEMOOaTSgNTxxx/f7KFQ3AYNGpSFPeXYeeedK732hBNOqPM1ESQUBu7jtuqqq9a4boQEpduP8KG6gd1S+++/f7VzRdSmPvMP1TQPU1OJwfTa5muJge911lknm8MkBlUnTJjQKOdfBEQff/xx2fsYgWF1Ntxww0rb3W677WqdSyUG+quGU7X9FqqGQoW5l1555ZWy9v3SSy+t9NpNNtkkm7+qNjHYXzpPT3wH1QUUrSkUGjx4cKXtXXvttdWuV+7g/oEHHlhprrBRo0aVvS8//vhjjc81dG6a2ubI2XXXXcuei2tWQqHCLYKt2sS8VaXztcXx+/DDD1skFGrIurP6vV1//fWV1p1vvvkq3nvvvVq3f+SRR1Z6zYABAyqFebUds8LtrLPOqvU93nrrrUrfS9++fZtlDjcAoO1rvT0DAABo1aq2X+vVq1ez70OHDh2yVl/RNqsu0RLqlltuKS5HC7m//e1vdb4uWkudccYZxeVo3xQt36pz4YUXVlqOeTpirpnanH/++VlLsvaie/fu2fxOCy64YI0tq6LtXxybaHkU7buiRdbJJ588U2ummkRrwCuvvLK4PMccc6Q777wzDRgwoOx9jDZ+VY0YMSI9/vjjxeXYt6uvvjp16tSpxm1F27Hrr7++Uju2yy+/PPuc5YrzMNrd1SXaUkV7sdL9i5Zw8flrE7/NG264Ifu9hNi32MfWbK655qq0HO3jZkW0XiuINo7RLrJczdkqrX///unSSy9ttvZ+Q4YMyVpj1maTTTZJhx12WHE52qJddtllKS/iGl3qggsuSEsuuWStr4nWb3GelZ5/d911V9nvGXOFHX300bWus9xyy6UddtihuPz5558XWwECANRGKAQAQIOUzjXRUqHQ+uuvX9ZgeohBzNI5Yk499dSy32ffffdN3bp1Ky7fd999M60Tc7jcfffdxeUIKPbcc886tx3zeMS8E+3Jsssum1577bW0xx57lDW4PXLkyHTKKaekxRdfPB1xxBHZHBm1iRCmNHSJeYuWWWaZWd7vG2+8sdLyn/70p7LO69VWWy2bF6g0wHjooYfKes8Ipw444ICy1o25mUaNGlVcjkHjmAOrHBFubrDBBrWew605FIr5vBpLuXNLtYT9998/m5+mudQ1R1rBcccdl81rVNNvpb16//33K81NF9e2nXbaqc7XRZAcQXepCGbLVe5/E7bYYotKy2+88UbZ7wEA5JdQCACARlGoQmhOpQPxdYnJ0QsWXnjhssOkQqVADPwXPPvsszOtExVE06ZNKy7HX3CXe0yiYqa5KgOay3zzzZdVcb377rvp+OOPr/Mv6wsVCP/+97/T2muvnU3sXs53Gfbbb79G2eeqFWC77LJL2a/ddddda91WTYYOHZpVLpWj6ucurRIox7rrrlu8/+qrr6apU6em1qo0wG0MSy21VPF+VKRddNFFqTWqzzWtMX6jpUFhbaIac8MNNywujxs3rlL1VXtV9Xe88847l/3aCGxKw81yrwnxBwjrrbdeWetWrURtzYEnANB6dG7pHQAAoG3q3bt3peXvvvuu2fehtD1PbaZMmZJef/314nK0axszZky93qv0r/ere21UxpQqDZHKOZaxTx988EFqb5ZYYol02mmnZbcYSI7WcfGX9xGivfjii9VWgLz11ltpm222yQZRo5Kqqnhd6WB1/PV+Y4igpKBfv35poYUWKvu1gwcPrnFbjXEOVw0jo8IopvKpz3lcWunx888/Z99HfdqoNaeq15O6WuTVJQbzzzvvvOLyH/7wh6yd19577521kqx6PWsJUV0SLcGay8orr1yvMDquaaUVcHGOl9uysa2q+jteY4016vV7i2P8xBNPFAObTz75pM7rSlRMdu5c3lBN1UrGSZMmlb1/AEB+CYUAAGiQqoOo3377bbPvQ/ylezliroWYj6Ug5o2ZlcHwqvMpFea5KVXfeYJiILA9hkJV50vZcccds1shmIjv4pJLLkn33nvvTCFbVHNEC7dSUY01ceLE4nJdczaVK9rR/fDDD5XCrPqIgd4ILgohV7l/sV/uORxKq6diX2c10InzuLWGQlWvJ/U5TtVZffXV08EHH5wuvvji4mOPPfZYdotgJNrrRYVaVGhE9cz888+fmltUlZQGd02tIdeoUl988UVq76r+jut7XYgKtUIoVNheXaFQfVqxVj1fSqtVAQBq0r56VAAA0KwD/KVaYoLrqJZoaIgzK0rDg5oGscud66Ul52RqabPNNltWpXHPPfdkoVDVapCqE7xX911WnXumoWb1+6v6HVadc2tWz+HmOo9bg6iAqno9qU/VVk0uvPDCrGKtdH6wQqu6mIslAqOoKIoqsQiGbrvttmxfmkt9zoXGMKvXqJb4Q4Dm1tjX9XKuC+2tlSgA0Pr4XxsAADRIQ9tltQR/Pd36bbXVVumf//xnpcdi7pcPP/yw1c1l1V7O4+YMPOrj/fffn2kwfoUVVpjl7ca5EvNbjRo1Kp155plpzTXXrLZNV4REw4cPz+b6Wn/99dP48eNn+b0BAKC1EAoBANDgUKh0QDXmNolB/NZo7rnnrrQc7ctiQHxWblVVrVip79wOLTEnU2uzzz77pO7du1d6rGpLvarfZWNVK8zq91f1O2yKOWpKP3u0N5vVc3jIkCGpNSpttxWWXHLJmb73WdGnT590zDHHZHNWReVGtDA8+eSTswCoakj09NNPp8022yxNnTo1tTezeo1qrCq90jCutWns63prmLsKAEAoBABAg8Tg/VprrVXpsauvvjq1RlXnB/nqq68a/T1ioLnURx99VK/X11URk5d2clXnCKrabinm0CgNCN59991Gee/ZZ5+9Uvuu+n4fMd9PYT6hxpgDp67zOFrJtcZB9MYQc0yV2nLLLZvsveI7Hzp0aDrppJOy6qCoCvr73/9eqZXhm2++ma666qrU3szqNaqmeZdKg7Vffvml7O23xnZ0VX/H9b0uRNVbbdsDAGgJQiEAABrsD3/4Q6Xl//znP2ny5MmptYm/zo5qg9JWd9OnT2/U91h55ZUrLb/88stlvzaCj/oO0JarrbVXqzqfRnVzeETbr9KA75133mmU915llVWK98eNG5cFPeV64YUXatxWU7RsjMH21157LbU3d911V3rrrbdmqiBrLvPOO2/6y1/+kq644opKj8ecV+3h91UqroP1CRarXtNqOsdLf7P1CXpGjhxZ9rrNddyrfsYXX3yx7NfGb7S0rWoEQo0xNxYAwKwSCgEA0GC/+c1v0sILL1xc/uyzz7I5O2ZVzPnR2DbaaKNKLX0efvjhRt3+qquumlWxFNRnkvpbb721yao+unbtWmn5559/Tq1VBHVVw7F+/frNtN4GG2wwUxjZGKpWvt18881lv/b//u//agyumuIcDrfccktqTz755JO03377zTTX1HLLLdfs+7LTTjtV+u1Ee8y2/vuqKgLVYcOGlbXuxIkTszZ7Bf37908DBgyodt3Sapj4PZdbLVSfa3JzHfdZuSY88MADlUKxprgmAAA0hFAIAIAG69SpU/rnP/9Z6bELL7ww3XDDDQ3eZmxv//33T40ttln61+V//etfG3WekGint8022xSXx44dm6699to6Xxf7cNZZZ6Wm0qtXr0rLn3/+eWpKb7zxxixViURbtNIKrxVXXHGm9Xbfffes3VvBpZdeOlObpobYddddKy2fd9556YcffqjzdVGxc/fddxeX55lnnrT55punxvbrX/+6Ukh28cUXp9GjR6f2IFq0RdgX4UPpb6rq9aW5RAu00vmtorVha/h9NbZolVeO008/PU2bNq24/Lvf/a7GdUt/sz/99FPWlq+cPwSoT8jZXMc9KkxLq4XefvvtdOedd9b5ugj5TznllEqP7bbbbk2yjwAA9SUUAgBglmy33XbpoIMOqjQYtscee6RzzjmnXi3aYlB/2223TUceeWS95qEoVwxUxvYLXn/99SxcKJ0Hpi5R+XPfffelL774otrnDznkkErLRx11VHrvvfdq3ebhhx/eZK3jwlJLLVVpudzKgIZaaaWVsiqLGDytj5gbqGo7wqhEK52fpHQuk9KKkilTpmTfbbnt3qLFYXVhT1SklFYhReXb73//+1qruCLEiMHe0nXiNaWhVWOJbf75z3+u9DmikiYCyPr43//+l1555ZXUGsQcPhHQRhVF6e8g2gheeeWVaYkllmi0eYq+/PLLstd/8MEHKwWUVX9HLfX7amwR2MS1ujaPPvpoOv/884vL8ZusLbivWskXwVNtv6FJkyalnXfeuV7VPjHnU2krtqeeeqrRW4IWHHbYYZWWDz744DqrWY877rhK7R1jX+O/lQAArYFQCACAWRZ/zb/uuutWCk+OPvrotMIKK2RVQ6V//V8qBgEfeeSRtO+++6ZBgwZVqrZoClFRUjqQGG3bou1b/IV6TQOSMZgZVQzxV99LL710Vq1ROlhcasiQIVkgUhCfe/3110+33377TIOiMRgeA6GXXXZZtjzXXHOlprDaaqtlA6gFZ555ZjrttNOyOXBiED7aYhVujTHRe3z3cTyXX375bKA/jnltoVcEbGeccUZaffXVK/21f1RpVP1L+6qVC6UD8jGvUHyXl19+eRYSVSeCqpgvZuDAgTVOGB+VbqWBzn//+98seKluEDgGotdee+1Kcxotuuii2Xs0lQjOSquQYh6W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syqiX375JRvEOvnkk9OoUaOy6p+aRGCz3XbbpSeeeKL4WFQbrbzyyumnn35KTz/9dBo/fny23vHHH5/9e+KJJ9a5f7HOqaeeWlzu169fWnfdddPss8+eXn311eKJdNNNN6WJEydmYVfnzm3jVIi/GJ88eXJxOVrGCYQA2pe4rsf1/csvv8yW47of1/+6/tACAAAAgNq1jSSgFYi2cL/+9a+zlmsxt0F1ot3NH//4xyxsCdddd132mh122KHa9SO4KQRCEdhcffXVaeeddy4+//PPP6e//vWv6eyzz86WI2haf/31s1ttbehKA6Gjjz46/f3vf0+zzTZb8bHYv3322ScLnh555JH0j3/8o6ywqbXMJ1Ra3BathgBof0qv73Hdj+u/UAgAAABg1hhdKdNRRx2VDjjggBoDoRDz2tx4441p6NChxceiPVt1vvjii/TPf/6zuPyvf/2rUiAUIsg566yz0k477VQcFKurhVzp87G9eH1pIFRoG3feeecVl88555xszqS2oHQuoWCAEKB9qnp9r3r9BwAAAKD+jKg3Qcubvffeu7j8+uuvV7tetJUrtEFbcskls3mFahLBTmFw7Pnnn69xmy+//HJ2C7F+vK4mEXAtscQS2f3vv/8+XX/99akt0joOoH1yfQcAAABofEKhJjDffPMV70fgUp277rqreH+vvfaqdfBrwIABlaqP7rzzzjq3udFGG9U6KXe835577lnnNgEAAAAAgPZBKNQERo4cWby/8MILz/R8zOXzwgsvVJqvqC4bbLBB8X5hHqKqhg0b1uBtPvfcc2nq1Kl1vgYAAAAAAGibhEKNbNy4cdkcPQU77LDDTOu89957xbkRomJnpZVWqnO7K6+8cvH+O++8U+06pY+Xrl+T0veNCbzff//9Ol8DAAAAAAC0TUKhRjBlypSsOujcc8/NgpYIhsIyyyyT/vznP1cbChXMP//8afbZZ6/zPaKFXMHXX3+dvvzyy0rPf/HFF+nbb78tLg8cOLDObc4xxxyVWt29++67db4GAAAAAABomzq39A60Rc8880xad911a11niy22SDfeeGOac845Z3pu4sSJxft9+vQp6z379u1baTmCodJAp3Sb9d1uIWCKbQIAAAAAAO2TUKiR9e7dO1188cVp5513rnGdH374oVK1Tjmqrle6jeqWG7Ldqtuoj4suuij73OX46KOPGvw+ANXZa6+90rXXXpvdv/rqq7PlWRHzwX388cfZ/dGjR1c7PxwAAAAAtDVCoQbo379/OuSQQ7L7FRUV6fvvv89awr322mvpm2++Sbvssku6/PLL06WXXpqWXHLJmV7/008/Fe/PNttsZb1n165dKy3/+OOPNW6zodutus36iGqjaKEHDbHbbrtllXUFZ5xxRjr22GNbdJ8AAAAAANoboVADLLroounCCy+c6fGYS+gvf/lLuuaaa9KwYcPS4MGD0/Dhw9MKK6xQab3SOYR+/vnnst5z6tSptVYCVZ2XKLZbzlxFpdstt7qoOtHKbtllly27Uqjq52kuJ5/cIm/b6rSm4xCh6p133lnpsaj4EAoBAAAAADQuoVAjVxBF26KePXum888/P6saijZyb731VurUqVNxvR49etS7OqfqeqXbqG451i8nFCrdbtVt1EdUThWqp+oyaNAgVUUU3XrrrWnKlCmVHnvnnXfSyy+/nFZbbbUW2y8AAAAAgPamY0vvQHt0+umnZ8FQYXD7wQcfrPT8PPPMU7w/YcKEsrb5+eefV1qee+65a9xmQ7dbdZvQHArzwFStVit9HAAAAACAWScUagLdunVLa621VnH52WefrfT8UkstVbz/xRdfzDQfUHXGjh1bKbyJdm2l5p9//jTXXHMVlwsTpNcm3jfmAipYeuml63wNNKbRo0enp59+OrvfoUOHdM455xSfu+mmm8purwgAAAAAQN2EQk2kd+/exfsTJ06cKRTq2PH/PfQVFRXpf//7X53be+2114r3l1lmmWrXKX389ddfr9c2o73dkksuWedroDFdd9112W8grL/++mn//fcvBp5ff/11uu+++1p4DwEAAAAA2g+hUBMZP358jW3ZYq6fwYMHF5eHDx9e5/aefPLJ4v2hQ4dWu84GG2zQ4G1GZVPXrl3rfA00lgiDIhQq2H333VPnzp2zebjq20JuzJgxWaVR3BZeeOHi448//njaZZdd0mKLLZa1povAad11100XXnhhmjp1ap3bjW0VthvvEd599910xBFHpGWXXTZrExm3FVZYIf31r3+dqc1jdfbaa6/iNq+55prssW+//Tb9+9//Tuutt15aYIEFsuMQz8fjVX311VfpjDPOyEK0fv36Zb/beeedN6200krp6KOPrnW+rmnTpmXrFt7/+eefT+XaZJNNiq87++yza1035oP64x//mH71q19lx3y22WZLffv2zfb5zDPPzOZbq4+77rorbbPNNtmxic+74IILpo033jhdf/316ZdffkmtQVRnnnjiidm1vU+fPtlnjn9j+aSTTkqffPJJ2dt69dVXszakW221VVp00UWz+d4K24tr9V/+8pdK1aP1PYc//fTTdMIJJ6QVV1wxqzDt3r17Vil66KGHllVlGn744Yd06aWXpi233DINGDAgq5Dt0qVL6tWrV7atX//61+kf//hHevvtt8v+3AAAAAA0vQ4VhT/Tp9FEZVAMXhYGnWPgd88996y0TgyqHnPMMcXKoRhorkkMJi6yyCJp+vTpxQHDlVdeudqB2NVXX71Y+RMDgDF4WpN43/fffz+7f+6556Y//elPqTkMGjQoG7iOQfURI0bU67VxTEeNGlVcjgHT+oRZJ59cr7drt1rDcYi2cRGCFILSmAcrApbS8zgGmT/77LOZ2iVWFed6/EbCwIED0wcffJD+8Ic/pMsvv7zG10RlXYQNtVXIxYB6YZA8Wt09+uij2cB5TYFSVAjG733rrbeuNRQqhF1XX311WmKJJbLgqrrQIMKT0raQV111VfY7/e6772rcfvz2Yx+jFV/cr+rggw9Ol1xySfH+RRddlMoJuRdaaKHsGhRVjhFIxDWuuv39/e9/n26//fZatxef6Yorrkg77LBDncHDTjvtlB544IEa11lnnXXSrbfemv785z9XOq5xnGdF1e++NGys6rTTTkt///vfa20FGuf4ySefnI499tha3zfO/fgN1CV+G/Gehf+OlPs5ojI1jk1N51CEp3E8I+ypSYSJv/3tb7PfZjkijIygs75m9XoPAAAA0BYNmoXx83LUf5Qmh6KNVdVqn5rMmDEjG4wuDBrHAFb8tXdVERKdcsopafLkyem9995L//nPf9J+++1X7TZjELEQCK255prVBkJhtdVWy24xoBjrxyDpDTfcUO26MVheCITmnHPOtMcee5T1+aCxlFYBRRVIBEIhzuGoNIigNAaT/+///i8dfvjh9dp2/GYKgVBU8UTFSuTfEagWKmneeeedrOouBrgj8KjL3XffnVUIhQhEIoyICo74HcW8YfHbj1Akgo577703bbrppnVu88MPP8y2GQP08TuMkKx///7Zdp566qlK60bIE5VABXFticqbqNKI9YcNG5Zdq+K3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5zajvV1psLLyyiunWbXSSisV7z/33HNlvaZ0vdr2IdrX/eY3v5mpWigqUO69996y2vzFd1Twv//9r1Eqdko/cznHPULAt99+OzWnxv5eSudmWnrppSvN7VOdZ555JjvXW5sIic4999xKQVC0fgQAAACgdRAKAc2itMpn8803zwb7y7m99NJLWfusguuuu67O97r++uvrXKd0OxtssEGd60eVU8y9U5sIVQrr9OvXL2u7NquGDh1avP/666+nN998s9b1o93af//732pfX24Ludtvvz39+OOP2f211lorLbLIIjW+PlqGLbPMMsVqsGgZNqtKv49oGRityGoT68R8O82p9Lg+8MAD6Ysvvqh1/Qh9oh1cda8PHTt2rFfLvEsuuSS1ZtFKsGDChAktui8AAAAA/P+EQkCTizlFSgfEa2tHVp3S9SPwqatCIlqf1VYtNGzYsCz4KNhvv/3q3Idog3XeeefV+HwMfP/tb38rLsecK1FlM6uiamS99dYrLv/hD3+oNST561//WgwoevbsmXbddddatx/hxAILLJDdHz16dFbNUjq/0O67717nPh577LGV3v+tt95K5aqu5Vzsc7du3Ypt1c4888waXx/zHp144ompuW2yySbFsCwCqSOOOKLGdeN8PfTQQ4vf22KLLZY22mijSuvEtgrnS1Q9jRo1qtYQLCrvWkK0XaxvO7/555+/CfcIAAAAgProXK+1ARrg//7v/4oD4jGHza9//et6vX6XXXbJ5liJwfWYvyVCnZoqYKLtVrzXVlttlVX3VB18v//++7N2aIVgaeONN04bbrhhnfsw22yzZeFHbD8G+EsrO95555200047FcOYaEf3xz/+MTWW008/PQuGogrp6aefTttvv336z3/+U2mwPap0TjrppErBVSz36NGj1m3H54jje84552TLZ599dnriiSeKn3nHHXcsK7SLyqt4XbSeW2edddJZZ52V9t5772wb1c2HE+3prrjiimxunltvvbXS87169UrHHHNMOvnkk7PlCH3iuB911FHFuWpCzMETxz2qcOJ94hg0lzhuZ5xxRvb+Ic617t27Z8e/9JjH8Yjz5Y477ig+Fsem9PwpzDE0ePDgrK3hjBkz0g477JBts7TaLB6PCqE4t+I4xDEpnRerOQwYMCALCiO4W3fddWf6HOGVV17JPnNpZSAAAAD58f/933mq4djQGgiFgGZtHRdz2MRcNvUdiI4B6Keeeqq4vZpCoZgbaLvttkv/+te/ssBnxRVXTL/61a+yEOjVV19NI0aMKK4bLd4imChHDORHNUjcIkCJ4CMG/2OOo5jfJQbsC/MlXXXVVWnuuedOjSVauEUAcfTRR2fLEajEMYk2awsttFD65ptvsqAsqmYK4hiUG0xFqFMIhe66667i41tuuWXq3bt3na+PgOKWW27Jjne0uIvQ58ADD8yCnTXXXDOrRIp1Yj/fe++9LET75ZdfstdGwFWdCAEfffTR9Oyzz2bf3Z///Of073//O62//vrZcf/www+zgCyCspjXKKpvInxsThGYxTl50UUXZcsR1EUVT3wvEQxGSPj4449ncx4VxPlTOo9TqVNPPTWrQIpzKY7j8ssvn9Zee+2sRV9sIz7v+PHjs3VPO+20dPnll6ePP/44NadoKxjvG7cIeOO3NXDgwCwQiyqimFOr9DcWoV8h3AMAAACg5QmFgCYVrcRigLuhreNKX1cIhaL1WwzE11QFEwFOVGjE/DZvvPFGdqsqKjAiAIkB7XJss802qWvXrunwww9Pn376aaV5ewrmmmuuLBDaYostUmOLKpkIaP70pz9loUu0LHvooYdmWi/Cl2gxd+6555bdvi6Cs5i3KdqWlarPdzXPPPNkAU7sX4QjEfrEfj788MM1vibCwVVWWaXa56LyJ+bqiUqcwueMQKTqcY/A7LbbbstCpJZw4YUXpr59+6a///3v2XcS590999wz03qzzz57VvFU235GxVqc11FlE8cvKt6GDx+e3QqiMida9MV2IphpbvGbK4Rc8VkjqIpbTedVfF8R1AIAAADQOphTCGi2KqGozKmpwqcu0U4rQpkwefLkLAioSbTVimAiwoTf/va3aeGFF85eG9U7UXlx/vnnZ0FRzNdTH1H98tprr2WhS7w2BsjjFoFKDNKPHDkyq9BpKjFPUcxt9I9//COrnIpqlPis8bliAP7II49Mb775ZlYlVdpmrRxV5w6KgCsqheojQp5obxZt3aLqJSpmokooApEIeaJqJKp69t9//6yiJuYTqi0kiTmRYi6qCAGj5WCEL7GdCBkiQIkALgKTOK9aUoQ0UQEV/6622mpZK7ioGIt/V1999XTCCSdkz5cTXBXOsWi9F+dtfN5op7fssstm5120ZjvllFMaZb6qhohqtKjgis+66aabZlVMUSUU51tUDi2zzDJZmHjnnXdmn6O+vzEAAAAAmlaHirpmbIdGNmjQoGzwPAY5S9sMlSP+Er90AvYYkCwEBeTXmDFj0iKLLJLdj8qfWG4MMShfaM81evTobBloHq73AAAAbZMu4jVzbGjq8fNyqBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAORA55beAYBZtfDCC6eKiopG3+6YMWMafZsAAAAAAC1FpRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRBAMxk+fHjq0KFDdhsyZEiN6xXWiVveNOZnL/d4AwAAAEBedG7pHQDyIwbmn3zyyWqf69q1a+rVq1fq2bNn6tOnT1pppZXSKquskoYOHZoGDBjQ7PsKAAAAANDeCIXIl5NPbuk9aB1a4XGYOnVq+uKLL7Lbhx9+mJ599tns8Y4dO6bNNtssHXbYYWnTTTdt6d0EAAAAAGizhEJAi1httdXS6quvXlyeMWNG+u6779K3336bRowYkT7++OPi4w888EB222uvvdL555+f5pxzzhbccwAAAACAtkkoBLSILbbYIp1cS8XS559/nq6//vosBPr000+zx6655posMIoWdHPMMUdqryoqKlp6FwAAAACAdqhjS+8AQHX69u2bjj766PTOO++k3/72t8XHX3755axiCAAAAACA+hEKAa1ajx490s0335y23HLL4mO33HJLeuqpp1p0vwAAAAAA2hqhENDqdejQIV133XWV5hI67bTTynptVBodf/zx2fxFffr0SbPNNluab7750hprrJFOPPHENG7cuHrty/Tp07NQao899khLLbVU6t27d+rSpUuaZ555sm0efvjh6fHHH5+lFnDxeQu3miy88MLFdcaMGZM9Fm32TjjhhLTiiiumueaaK3Xv3j0tvfTS6dBDDy3O0VSuadOmZe37dtxxx7Toootmxz62t8gii6Rddtkl3XnnnfX6jDFf1Omnn57NJRXHLMK+OH6///3v06uvvppai4cffjjts88+ackll0w9e/bM2hQOHDgwbbfddln7wjgu5fjxxx/TXXfdlQ477LC0zjrrFM+9+Nzx3cX2rrzyyvTzzz/Xua3hw4cXv+shQ4YUH3/iiSfSzjvvnH0/s88+e3YOrrfeeunCCy8sez/ffffddMwxx6TBgweneeedN9vH2Nb888+fVllllbT33nuna6+9Nn3zzTdlbQ8AAACA1s2cQkCbMPfcc2dt4y644IJs+dFHH01ff/119nh1pk6dmgU0//nPf7Igp9RXX32V3V566aV0zjnnpLPOOiv94Q9/qHMfnn766bTffvul999/f6bnYl9ie3GLeZCOPfbYdMYZZ6TmEgFEHJ8IX0q999572S0CiFtvvbVSxVVtIUR8zo8++mim5yKAitt///vfLEi47bbb0gILLFDr9p555pm00047zRTAxXGM21VXXZVOOumkLKRrKV988UXadddds0CvqrFjx2a3OMb/+Mc/0v/93/+lVVddtcZtvfjii2mjjTZKP/zww0zPRVgzefLkLKSL7f39739Pd9xxR1pppZXK3tcIkuJ8veKKK2Y65+McjdvVV1+dBVwR9NQk5vSK96/6+whffvlldnvttdeyMOx3v/tduuGGG8reRwAAAABaJ6EQ0GbE3EKFUCiqVCJs2HrrrWdaLwbdN9100/Tss88WH1tsscWyyoeoUokAJ56LkCIqOqKSZtKkSVlFUU0iBInqoNIKjKgmicH8Xr16Za8fMWJEdpsxY0b66aefUnN57LHH0oEHHpgN7g8YMCCtueaaWZXL6NGjs4Dnl19+yT5nVP28/fbbWbVPTSI4igCg8DmjUibCn6hu6dixYxbiPP/889k2X3jhhey9Yp6nqISpTlQBbb755pUCkghUll9++SzciG1E+BShUHw3LWHChAlp7bXXrhSCxfkSlV9du3ZNI0eOzIKe8MEHH6QNNtggPfTQQ9lrqhNVNYXPGxU3gwYNSgsuuGBWaTVlypT04YcfZuFhHMMI2NZff/0sfFl88cXL2t/9998/q96J7yP2MarB4pyLYxkBYIjtxfn6wAMPVLuNf//73+mUU04pLkd4FN9zv379soqk+I1EFVFU2lUXGgEAAADQNgmFgDYjQp1OnToVB6ljELy6UOjggw8uBkIR3Fx22WWV2m6F2Mbll1+e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X3zxLL/35ptvXqkK5J133knt0VZbbVWpfdyECRNmaXvRVm3VVVctLl9//fV1vua6665LzW2DDTYo3r/mmmvqXD/WKcz9079//7TUUktVer4QhJXTNi8CpLvvvju1VhGwbrLJJsXlWT0nAAAAAGh5QiGgVYsgaOedd073339/8bHdd989rbnmmtWuf+yxx1ZqeRXVDuWqrm3a6quvntZee+1iOLXHHnu0y7mF4nMOGTKk2EotjvHPP/9c1mtjvW+++Wamx/fbb7/i/ZiPprTCpqr//ve/6ZlnnknN7YADDijev/POO9PDDz9ca6u30047rdJrO3ToUGmdRRddtHj/nnvuqXFbMTfW/vvvX/YxbkzxXRWCrfq09CutqgIAAACgbRIKAa1SBDTnnHNONifLLbfcUnx8rbXWSldccUWNr4u5UaKqKPzyyy9pyy23TKeffnqNQc5PP/2U7rrrrrTNNtukrbfeutp1ItAotKV75ZVX0nrrrZdefPHFWvf77LPPTm1NhGg9evTI7j/66KO1fs4QIc+pp56aFl544WpbzkWAVqikiaBp4403rnZ7N954Y9p7773TbLPNllqiUqi0GmyHHXaoNHdVwauvvpo22mij9O2332bLCy20UDrssMNmWi/Ot0JQNHz48HTUUUdln73qObL99ttnQWf37t1Tc4vqpCWXXDI7T0vnRyo1derUdOGFF6bbbrut+FjpcQIAAACgberc0jsA5FNhQvuCqFyIuWdi0D3mnxk9evRMr/n973+fzjvvvGJAU5PLLrssjR8/Pj3yyCNZJcbxxx+f/v73v2fz5gwYMCB7fbzPRx99lN5+++1sADysssoq1W4v5o258sor01577ZUFTa+//no211AEHiuttFI2t9F3332X7XdsLz7L4YcfntqamD/mpptuSjvttFPW+iwCnPiciy22WHYM5p577ixEi/ma3nzzzfTZZ5/Vur04ztE2LoKXyZMnp7Fjx2bbi6qkeK/4bl544YXi3E8RvlUXtDS1q6++OqsGi/MhwsMdd9wxLbHEEtn5EkFVfK9xLKJSLESQE8dprrnmmmlbSy+9dFZlVWiFd+6556b/+7//S6uttlpWaRMhTMwjFJ89WuxFeHjggQc2+2eOz3r00Udnt/hNxBxQhUqgCK3ie/n666+L6//ud7/LAlkAAAAA2jahENAiXn755exWl06dOmUVCkcccUTacMMNy9p2hBEROp1yyinZoHwEHHEbNmxYja/p0qVLFljUJAbF+/Xrl7VEKwRW7733XnarTqHipi3OLfTcc8+lfffdN6uOKQQIcatJVAotuOCC1T4XYUh8FxE0FdrzvfTSS9mtdB6eE044IR166KEtEgr16dMnq3Tadddd0xNPPJE99sEHH2S3qhZffPFiyFOTSy65JPusEUqGCCirtpKL4xUt86ZNm5aaW5ybUc1UCLkirItbdeK7idDqX//6VzPvJQAAAABNQShEvpx8ckvvATWIioyePXtmVTd9+/bNKnCicidadtUUONQVJv3tb3/Lgoao2oi5haLiI6qTYiA+3mvgwIFp+eWXzypZtthiizTffPPVus2hQ4dmIVAM5t93331ZK7momolKo9jvCAxirqPtttsurbvuuqmtWnHFFbPPFqFGtNaLwGTcuHFZdVUEbnGcokoqKmk23XTT7DNXnVunVLShe+edd9JFF12U7rjjjixgiu+gf//+2XMxN09UD7WkCIYef/zx9NBDD6Wbb745m98ogp3Yz6igifNx2223TbvttlsWINamW7du6cEHH8zCo2uvvTarLIsquHnnnTebcyhax0XVWe/evbMWc80tWuQVKuniu33jjTfSqFGjiq3x4lyO9nLrrLNO1gIwWjgCAAAA0D50qCj8qTA0k0GDBmWD8zHQOGLEiHq9NgbfY/CyIAZY62olBkDb43oPAADQNvmb7Jo5NjT1+Hk5Ojb6FgEAAAAAAGh1hEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFaNMqKipaehcAaAKu7wAAAACNTyhEm9KxY+VTdsaMGS22LwA0narX96rXfwAAAADqzwgLbUqnTp1Shw4distTpkxp0f0BoGmUXt/juh/XfwAAAABmjVCINiX+Urx79+7F5UmTJmkxBNDOxHU9ru8Fcd1XKQQAAAAw64yw0Ob07NmzeH/q1KlpwoQJgiGAdiKu53Fdj+t7ddd9AAAAABqu8yy8FlpEjx49sr8YL8w38c0332RthmLQsFu3btlzpS3mAGj9QVBc0+NaHhVCpYFQXNPjug8AAADArBMK0ebEvBIDBgxIY8eOLQZDMYD45ZdftvSuAdCIIhCK6735hAAAAAAah/ZxtElzzDFHNlBojgmA9h0IxfUeAAAAgMahUog2KwYKF1988fTDDz9k7YYmT55sbiGANixaf3bv3j1rBxot41QIAQAAADQuoRBtWgwY9urVK7tFK7np06cXW8oB0LYqg+KargIUAAAAoOkIhWg3YiDRYCIAAAAAAFTPCDoAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQCAAAAAAAIAeEQgAAAAAAADkgFAIAAAAAAMgBoRAAAAAAAEAOCIUAAAAAAAByQCgEAAAAAACQA0IhAAAAAACAHBAKAQAAAAAA5IBQqB7GjBmTrrjiirTbbrulFVdcMfXu3Tt16dIlzT333GmFFVZIBxxwQHryySfL3l6HDh3qdevcuXO99nfs2LHp5JNPTqusskqab7750hxzzJEWW2yxtMMOO6Q777wzVVRUNOAoAAAAAAAAbVH9Uoacev3119OBBx6YXnrppWqf/+abb7LbW2+9lS6//PI0ZMiQdO2116YBAwaklnLllVemww8/PE2ePLnS46NGjcput99+e9poo43S9ddfn/r27dti+wkAAAAAADQPoVAZ3nvvvZkCoSWXXDItt9xyad55503ffvtteu6559Knn36aPTd8+PC05pprpqeffjotuuiiZb3HIYccUuc6nTp1KmtbV111Vdpvv/2Ky3PNNVcaOnRo6tWrV3r77bfTyy+/nD3+2GOPpU033TQ9++yzqUePHmVtGwAAAAAAaJuEQvWw+OKLZ2FLtI9bYIEFKj03Y8aMdM0116RDDz00TZkyJY0bNy797ne/y8KiaP1WlwsvvLDRAqyoaiqIfbjssstS9+7di4898cQTWQu5qG56880302GHHZYFSQAAAAAAQPtlTqEy9OvXL1199dXp3XffTccee+xMgVDo2LFj2meffdINN9xQfOyFF15IjzzySLPu6wknnJCmTZuW3V977bXTddddVykQClE1dOONNxaXY5133nmnWfcTAAAAAABoXkKhMqy//vppr732Kqt923bbbZdWX3314vL999+fmsuECROyuYIKzjrrrCysqs7mm2+ezSkUpk+fni699NJm208AAAAAAKD5CYWaQFToFIwZM6bZ3veee+7J2tgV5jxaa621al0/gq6Cu+66q8n3DwAAAAAAaDlCoSZQOodQVOE0l2HDhhXvDxkypM71N9hgg+L9sWPHpg8//LDJ9g0AAAAAAGhZnVv4/dult956q3h/oYUWKus1Tz31VHrppZeyFnDRpm7eeedNK664YlbtU3VOoJqUzgu08sor17l+//79U58+fbL3LLx+8cUXL+u9AAAAAACAtkUo1Mii4uaJJ54oLhfm7Sln3qLqdOvWLe2zzz7phBNOSPPPP3+t23jvvfeK9wcOHFjW+w4YMKAYCr377rvp17/+dVmvAwAAAAAA2hahUCP705/+VGwZF4HLrIYsU6ZMSRdeeGG6/fbb0x133JEGDx5c7Xo//vhjdiuICqBy9O3bt3j/66+/bvB+XnTRReniiy8ua92PPvqowe8DAAAAAAA0jFCoEV177bVZeFNw+umnp65du9a4fjy3zTbbpC222CKtuuqqWYg0++yzZ+HMK6+8kq655pps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xRVT7969U5cuXdLcc8+dVlhhhXTAAQekJ598skHbfvnll9PBBx+cll122dSzZ8/sFvfjsXiuIcaOHZtOPvnktMoqq6T55psvzTHHHGmxxRZLO+ywQ7rzzjtTRUVFg7YLAAAAAAC0PR0qJAN1ev3119OBBx6YXnrppbLWHzJkSLr22mvTgAED6lz3559/TkcffXS64IILagxpOnTokA4//PB01llnZSFUOa688srsNZMnT65xnY022ihdf/31qW/fvqk5DRo0KI0cOTILvUaMGNGs7w0AAAAAs+Lkk1t6D2irnDu0hvHzzo2+xXbovffemykQWnLJJdNyyy2X5p133vTtt9+m5557Ln366afZc8OHD09rrrlmevrpp9Oiiy5a67Z///vfp+uuu664HOsPHjw4u//CCy+kUaNGZWHRv/71rzRp0qQs7KnLVVddlfbbb7/i8lxzzZWGDh2aevXqld5+++1i5dFjjz2WNt100/Tss8+mHj161POoAAAAAAAAbYlQqB4WX3zxLGyJ9nELLLBApedmzJiRrrnmmnTooYemKVOmpHHjxqXf/e53WVgUlT41hTeFQKhjx47p3HPPTYcddlh2v7DN888/Px155JHZ/Vh//fXXT3vssUetAVZUNRXEPlx22WWpe/fuxceeeOKJrIXcN998k958883sPWPbAAAAAABA+2VOoTL069cvXX311endd99Nxx577EyBUIggZ5999kk33HBD8bGo9HnkkUeq3ebUqVOz+X4KjjnmmHTEEUcUA6HCNuOxaC9XcOKJJ2Yt52pywgknpGnTpmX311577Sx0Kg2EQlQN3XjjjcXlWOedd94p40gAAAAAAABtlVCoDFGds9dee6VOnTrVue52222XVl999eLy/fffX+1699xzT/rkk0+y+9HWLcKcmkQQ1LNnz+z+xx9/XOM2J0yYkG6//fbicsxBVBoyldp8882zOYXC9OnT06WXXlrnZwMAAAAAANouoVATiAqdgjFjxlS7zl133VW8v9NOO6Vu3brVuL14bscddywu33nnnTUGTdFmrjDn0VprrVXrfkbQVd3+AAAAAAAA7Y9QqAmUziEUVTjVGTZsWPH+kCFD6tzmBhtsUGlOoMbe5tixY9OHH35Y52sAAAAAAIC2SSjUBN56663i/YUWWmim57/77rs0fvz44vLKK69c5zZL1/ns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AAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIAaEQAAAAAABADgiFAAAAAAAAckAoBAAAAAAAkANCIQAAAAAAgBwQCgEAAAAAAOSAUAgAAAAAACAHhEIAAAAAAAA5IBQCAAAAAADIgVYRCt13332poqKipXcDAAAAAACg3WoVodDWW2+dFlpooXTCCSek0aNHt/TuAAAAAAAAtDutIhQK48ePT//4xz/SEksskTbaaKN08803p59//rmldwsAAAAAAKBdaBWh0MCBA7P2cXGbMWNGGjZsWNp1111T//790x//+Mf09ttvt/QuAgAAAAAAtGmtIhSKlnGPPvpo2mmnnVLXrl2LAdHXX3+dzj///LTiiiumwYMHpyuvvDJNnjy5pXcXAAAAAACgzWkVoVDYcMMN00033ZTGjRuX/v3vf2dBUCgERC+//HLaf//9U79+/dJ+++2Xnn/++ZbeZQAAAAAAgDaj1YRCBb17906HHnpoev3119Mrr7ySDjzwwNSrV69iOPTDDz+kq6++Oq2zzjppueWWS//617/SxIkTW3q3AQAAAAAAWrVWFwqVWnnlldPFF1+cxo8fn6677ro0ZMiQ1KFDh2JANHLkyHTkkUemBRdcMO28885ZCzoAAAAAAADaWChUMPvss6fddtstPfHEE+mDDz5Ixx13XOrfv3/2XIRDU6dOTbfeemvabLPN0qKLLppOO+209Pnnn7f0bgMAAAAAALQabSIUKlUIfZ588sm0xhprZI9F9VAhIPr444/TiSeemAYOHJj22GOP9NFHH7XwHgMAAAAAALS8NhUK/fzzz+nmm29Om2yySVpqqaXSSy+9VKmdXNeuXYv3p02blm688ca0/PLLp8suu6yldx0AAAAAAKBFtYlQ6M0330yHH3541jJu1113TY8//niaMWNGFv5EKLTFFluku+66K33//fdp+PDhWau5aDkXz//000/p4IMPTg8++GBLfwwAAAAAAIAW02pDoUmTJqVLL700rbbaammllVZKF154Yfr666+LlUALLbRQOumkk9KYMWPSfffdl7beeuvUuXPntN5666XrrrsujR49Ov32t7/NthXrn3POOS39kQAAAAAAAFpM59TKxFxBV155Zbr99tuzKp9CqBM6deqUVQXtv//+afPNN08dO9acafXp0yfddNNN6Y033kjvv/9+evXVV5vtMwAAAAAAALQ2rSIUGj9+fLrmmmvSVVddlUaNGlUpCAoDBw5M++67b9pnn32yFnLlitBo3XXXzUKhaC0HAAAAAACQV60iFIpWcIUQqPBvtILbaqutsqqgTTfdNJs7qCF69OjRqPsKAAAAAADQFrWKUGjGjBnF+4ssskjab7/90t5775369u07y9teffXV05577jnL2wEAAAAAAGjLWkUo1KVLl7T11ltnVUEbb7xxo257l112yW4AAAAAAAB51ipCoU8//TTNN998Lb0bAAAAAAAA7VbH1AoIhAAAAAAAAHIQCgEAAAAAAJCD9nHTp09Pu+++e/rpp5/SEksskc4888yyX3vsscemDz74IM0555zp2muvbdL9BAAAAAAAaKtaRaXQAw88kP773/+mu+++Oy2++OL1em2sf9ddd6UbbrghPfzww022jwAAAAAAAG1ZqwiF7r///uzfLl26pB133LFer43143Xh3nvvbZL9AwAAAAAAaOtaRSj08ssvZ/+uuOKKqVevXvV6baz/q1/9KlVUVKSXXnqpifYQAAAAAACgbWsVodCoUaNShw4d0lJLLdWg1y+55JLF7QAAAAAAANBKQ6HJkydn/3bv3r1Br+/Ro0f276RJkxp1vwAAAAAAANqLVhEK9ezZM/v366+/btDrC69raKgEAAAAAADQ3rWKUKhfv36zNCdQ4XV9+vRp5D0DAAAAAABoH1pFKLT22mtn/44dOzY9+uij9XrtI488kj7++ONsTqI111yzifYQAAAAAACgbWsVodC2225bvH/QQQelr776qqzXffHFF9n61W0HAAAAAACAVhYKbbbZZmmVVVbJ7o8ePTqtvvrqdVYMxfODBw/O1o8qoRVXXDFts802zbTHAAAAAAAAbUvn1Epcc801aa211ko//PBDGjNmTBYULbHEEmnIkCFp0UUXTT169MieixBo2LBh6YMPPii+Np679tprW3T/AQAAAAAAWrNWEwoNGjQo3X333WnHHXfM2sdVVFRkwU9p+FMqng9zzz13uuWWW9Lyyy/fzHsMAAAAAADQdrSK9nEFURX02muvpV133TV17tw5C35qusXzv/vd79L//ve/NHTo0JbedQAAAAAAgFat1VQKFSy44ILphhtuSGeddVZ67LHH0gsvvJAmTJiQvv/++zTnnHOmPn36ZHMJbbzxxqlfv34tvbsAAAAAAABtQqsLhQr69++f9thjj+wGAAAAAABAO2ofBwAAAAAAQNMQCgEAAAAAAOSAUAgAAAAAACAHWuWcQhMmTEivvPJKGjNmTJo0aVKaNm1a2a898cQTm3TfAAAAAAAA2qJWFQq99NJL6fjjj0/Dhw9PFRUVDdqGUAgAAAAAAKAVh0L/+c9/0kEHHZRmzJjR4ECoQ4cOjb5fAAAAAAAA7UGrCIVGjBiRBULTp08vhjurrbZaWmmlldI888yTunTp0tK7CAAAAAAA0Ka1ilDovPPOywKhCIMGDRqUbrrppuxfAAAAAAAA2lEoNGzYsOzfOeaYIz344INpgQUWaOldAgAAAAAAaFc6plZg/PjxWZXQ0KFDBUIAAAAAAADtNRTq3r179q9ACAAAAAAAoB2HQossskj278SJE1t6VwAAAAAAANqlVhEKbb/99qmioiI9/fTTacaMGS29OwAAAAAAAO1OqwiF9t9//9S/f//0xRdfpAsuuKCldwcAAAAAAKDdaRWhUO/evdPNN9+czS109NFHpyuvvLKldwkAAAAAAKBd6Zxagaeeeir79/TTT09HHXVUVjkUFUO//e1v0/LLL5969eqVOnToUNa21ltvvSbeWwAAAAAAgLanVYRCQ4YMqRT6xPxCb731Vnarj9jGL7/80gR7CAAAAAAA0La1ilCoEATVtgwAAAAAAEAbD4Wi5Vu57eEAAAAAAABoo6HQ8OHDW3oXAAAAAAAA2rWOLb0DAAAAAAAAND2hEAAAAAAAQA4IhQAAAAAAAHKgVcwpVJ1vvvkmPfvss+mTTz7J7v/yyy/pxBNPbOndAgAAAAAAaJNaXSj04osvpr/97W/p4YcfThUVFZWeqxoKTZgwIW2zzTZpxowZac0110z//ve/m3lvAQAAAAAA2oZW1T7ujDPOSOuuu2566KGHsqAnQqHCrTp9+vRJffv2Ta+88kq67LLL0sSJE5t9nwEAAAAAANqCVhMKXXTRRen444/P2sRFCLT00kungw46KK266qq1vm7vvffO/p02bVp64IEHmmlvAQAAAAAA2pZWEQqNHz8+HXPMMdn92WefPV199dVp5MiRWVC0xhpr1PrazTbbLHXt2jW7P2zYsGbZXwAAAAAAgLamVYRCl156afrxxx9Thw4d0nnnnZf23HPPsl8bgdByyy2XVRe99dZbTbqfAAAAAAAAbVWrCIUefvjh7N/+/fun/fffv96vX2yxxbJ/P/7440bfNwAAAAAAgPagVYRCo0aNyqqE1l577ezf+pprrrmyfydNmtQEewcAAAAAAND2tYpQ6Lvvvsv+nXvuuRv0+qlTp2b/dunSpVH3CwAAAAAAoL1oFaFQ7969s3+/+eabBr1+7Nix2b/zzjtvo+4XAAAAAABAe9EqQqGBAwemioqK9Oqrr9b7tZMnT04vvvhi1nZu2WWXbZL9AwAAAAAAaOtaRSi00UYbZf9+9NFH6emnn67Xay+44II0ZcqUStsBAAAAAACgFYZCu+++e+rUqVN2/4ADDkhff/11Wa97/PHH08knn5zd79atW9pjjz2adD8BAAAAAADaqlYRCi299NJp3333zVrIvffee2nw4MHpvvvuy5arM2rUqHTUUUelLbbYIv38889Z67gjjzwyzTPPPM2+7wAAAAAAAG1B59RKnH/++entt99Ozz33XNZGbptttkk9e/ZMXbp0Ka6z0korpc8//zx98cUX2XIhNNpkk03SSSed1GL7DgAAAAAA0Nq1ikqhMNtss6VHHnkkayUXYU/cvvvuuzRx4sSsEii8+eabacKECcXnw5577pnuvvvu4joAAAAAAAC04lCoMC/Qtddem5588smsUqh79+7FAKg0COratWvafPPN0/Dhw9PVV1+dBUoAAAAAAAC0gfZxpdZdd93sNn369Kw6aNy4cVnVUIREffr0Sb/61a/S7LPP3tK7CQAAAAAA0Ga0ylCooFOnTtk8QnEDAAAAAACgnbSPAwAAAAAAoGkIhQAAAAAAAHJAKAQAAAAAAJADrWJOoaFDhzbKdjp06JAef/zxRtkWAAAAAABAe9IqQqHhw4dngc6sqKiomOVtAAAAAAAAtFetpn1chDr1vZW+rjlMnz49vfnmm+nKK69MBx10UFp11VXTbLPNloVRcRsyZEjZ2xozZkzxdeXeFl988Xrt7zvvvJOOPvrotMIK/097/wEnVXXwj/+HDoKAKCJKMaIIWCJERbAB9hSjsaJR7Bp8osZHY0lUsOWrMRqTaOygRo0lttiiglgC9k6xoIAiXYp0hP29zn3+e/+7y3Z2md257/frNe69M+eeOTN7j3eZz5xzdgzt2rULLVu2DN27dw9DhgwxogoAAAAAADKmTowUeumllypVbs2aNWHhwoXho48+Cg899FAYP358aN68ebjuuuvCdtttV6ttfPzxx8Oxxx4bli5dGuqDq666KgwfPjysWrWq2P2fffZZcrvnnnvC4MGDw6233ho23HDDnLUTAAAAAADIUCi09957V6n8IYccEi655JJw8803h7PPPjtcdNFF4dlnnw39+/evtTYuWLCg1gKhGMocf/zxFZZr3759peq79NJLwxVXXJHud+zYMey5555JgPbOO+8kYVr0wAMPhHnz5oWnn346NG5cJ04FAAAAAACgltTrJGDo0KFh2bJlyRRpRx11VPjggw+SadJqU4cOHcIuu+yS3v7zn/+EG2+8cZ3qjG3+29/+ViPti9PCFQ2E4ntz5ZVXJtPcFYph0EknnRSWL18enn/++XD11VcnQRIAAAAAAJC/6syaQtV1zjnnhE033TR888034bbbbqu15znwwAPD1KlTw8yZM8O///3vJEQ56KCDQtu2bUNdEkdNFTr66KPDtddeWywQiuK0cTfccEO6H6ffmzt37nptJwAAAAAAsH7V+1CoUaNGYa+99goFBQXhkUceqbXn2WyzzUKXLl1CXfbWW28lt6hhw4ZJIFSW008/PWyzzTbJ9nfffRfuvffe9dZOAAAAAABg/av3oVBUOGXcl19+GbLs8ccfT7f33Xff0Llz5zLLNmjQIAwZMiTdf+yxx2q9fQAAAAAAQO7kRSg0ffr05GdcIyfLXnrppXR7wIABFZYfOHBguj127NiwYsWKWmsbAAAAAACQW41DHgRCo0ePTka+dOzYMdRH33//fXjhhRfC22+/nazt07x587DJJpuEnXfeOey6666hWbNmlapn4sSJ6XafPn0qLN+7d+90e/Xq1eHTTz8NO+ywQzVfBQAAAAAAUJfV61Dok08+CYMHDw7Lli1LQqFBgwaF+hps7b///qU+ttFGG4WhQ4eGCy+8MLRq1arMOmbPnh0WLFiQ7nft2rXC523RokVo3759mDNnTrI/adIkoRAAAAAAAOSpOhEKXX755VUaVTNv3rzw/vvvhzfeeCMUFBQk9zdq1Cice+65Id/Mnz8/XHXVVeGRRx4JTz75ZOjevXup5eJ7UlSHDh0qVf9mm22WhkLffvttDbQYAAAAAACoi+pEKDRs2LBkpE9VFQZCDRs2DLfeemvo0aNHqE823HDDcNhhh4UDDzwwmcptiy22CE2aNElG/bz++uvJa3rxxRfTUVGxXAzC4uiekhYvXrzWKKDKKFquZB1VcdNNN4Wbb765UmUnT55c7ecBAAAAAADqcShUNOCpisIp4+JImrj2Tn0S1z/65ptvSp0SrlOnTuHwww9Pbrfddls444wzkvfnyy+/DBdddFG444471jpm+fLlxfabNm1aqXYUXa8oTsNXXXG00YQJE6p9PAAAAAAAkIFQ6LLLLqt02TiSpnXr1mHLLbcMu+yyS6WnSatrYhhTNJApy2mnnRamTp0arr766mR/5MiRSQhW8nU3b9682P7KlSvXuq80K1asqPLootLE0Uu9evWq9Eihos8LAAAAAADUvnoXCmVRHB10ww03JCN5Vq9eHV544YXwy1/+sliZkiOOYtnKhEJFRweVNmqpss4888zkVhnbbbedUUUAAAAAALCeNVzfT0jVxbCmb9++6f7EiRPXKrPxxhsX2581a1al6p45c2a63a5du3VqJwAAAAAAUHcJherRGkSF5s6du9bjm266aWjbtm26H6ecq0hchyiuBVSoR48eNdJWAAAAAACg7hEK1RNLlixJt1u2bFlqmZ49e6bb7733XoV1vvvuu+l2o0aNQvfu3de5nQAAAAAAQN0kFKonioY8m2++eallBg4cmG6PGTOmwjpffvnldLt///6hWbNm69xOAAAAAACgbmoc6oCTTjqp1p+jQYMG4c477wz10Ysvvhi++uqrdH/AgAGlljvkkEPC1VdfnR7z9ddfh06dOpVZ78iRI4sdCwAAAAAA5K86EQrFcCKGNrWtroRCK1euTH42bdq0wrJxzZ8zzjij2BRxffr0KbXsLrvsktzeeuutsHr16nDhhReGf/zjH6WWve2228Knn36abG+44Ybh+OOPr+arAQAAAAAA6oM6M31cQUFBsVtp91Xl8dLK1xXffPNN6NatW7j22mvD1KlTSy0T2/z0008nIc/kyZOT+2Jwdt1114WGDcv+tf3h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AEDVCYVyoOgaQitXrqzUMSVDlJIjgUquSxTrrcxaRUXrrezootKceeaZya0ytttuO6OKAAAAAABgPWu4vp+QEFq1alXl0TklyxWto7T96tRbsg4AAAAAACB/CIVyYOONN063Z82aValjZs6cWWy/Xbt2ZdZZ3XpL1gkAAAAAAOQPoVAObLvttun27Nmz11oPqDTTpk0rFt7ENXyK2nTTTUPbtm3T/alTp1ZYZ3zeOXPmpPs9evSoVPsBAAAAAID6RyiUo1CoYcP/e+sLCgrC+++/X+Ex7777brrds2fPUssUvf+9996rUp2NGjUK3bt3r/AYAAAAAACgfhIK5UDz5s3Dbrvtlu6PGTOmwmNefvnldHvQoEGllhk4cGC16+zfv39o1qxZhccAAAAAAAD1k1AoRw455JB0e+TIkeWW/eqrr8KoUaNKPbasOl988cXw9ddfl1tv0ectq04AAAAAACA/CIVyZMiQIaFly5bJ9ieffBLuuOOOMstecMEFYfXq1cl2v379Qp8+fUott8suuyS3KJa/8MILy6zztttuC59++mmyveGGG4bjjz9+nV4PAAAAAABQtwmFcmTTTTcN5557brp/1llnhYceeqhYmVWrViXBzgMPPJDe94c//KHceos+ft999yXHx3qKis9zzjnnpPvnnXde2GSTTdbp9QAAAAAAAHVbg4KCgoJcN6K++PGPfxy++eabYvfNnDkzzJo1K9mOI3+23nrrtY575plnwuabb77W/TGsOfDAA8Po0aPT+3bYYYdkJNDy5cvDK6+8EmbMmJE+Nnz48HDppZdW2M5LLrkkXHnllel+fO4999wzWcvonXfeCR9//HH62H777Ze0r3HjxmF92W677cKECRNCr169wvjx49fb8wIAAAAUNWxYrlsAQOT/x+vv8/P1lwTkgfiLmDp1apmPL1myJHzwwQdr3b9y5cpSyzdp0iQ8+uij4bTTTktHCX300UfJrWS5YcOGhYsvvrhS7bz88stDs2bNkp8xeIpB1oMPPrhWuaOPPjrceuut6zUQAgAAAAAAckMakGNt2rRJAptTTz013H333WHcuHHJ6KAYBHXu3DkccMAB4eSTTw49e/asdJ0NGjQIv//978Nhhx2WrFX0/PPPh6+++ioJiDp27JisSxTXNNp3331r9bUBAAAAAAB1h1CoCqZMmVJrdceApqZDmhgk/elPf6rROgEAAAAAgPqpYa4bAAAAAAAAQO0TCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABA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//778MILL4S33347zJ07N/kjdpNNNgk777xz2HXXXUOzZs0qVY8+Qq58/PHHYdGiRcl2mzZtQrdu3ZLz+t577w3/+Mc/kr4yf/785Lzecccdw8EHHxxOOumkMs9t1xHyUU33k5JcS8iKu+++O93+8Y9/nHwAWBrXErKssv2kJNcS6rsYgh500EHpaJorr7wyHHDAAWWOxLngggvSa8U+++wTunfvXua5vNlmm4WOHTuu87UkfoYwY8aMUstXps7p06cnf1MWHeEEeSvX89dBltZLKe8W5/q94447yp2P+8EHH0zLd+jQoVLPP378+GLPM3v27LUWiS36+MSJEytVb/v27dNj4pytUBf6SMm5u8u7bbTRRgW/+93vCr777rty69NHyKXbb789PY+23377ZN74XXfdtdxzu0uXLsl886VxHSEf1XQ/iVxLyJpPP/202Dla3roKriVkVVX6SeRaQr6J51yrVq3S86lbt24FI0eOLPjss88Kli1blvwN9tRTTxXsscceaZlevXqVuvbVNddck5bp27dvpZ7/6aefTo+J6xWV9MYbbxTrI0uXLq2wzriuUNFjyvv7EPKJ6eOgjohDZ0855ZTk26tLliwptcy8efPS7Q4dOlSq3viNi6K+/fbbMuusbr0l64Rc9ZGqiN8av+qqq5Jv58VvzJVFHyGXvvrqq2L78dt5b775ZrLdo0ePcNxxxyVrORT9htu0adOSqRLeeeedtepzHSEf1XQ/qQrXEvLFPffck25vvPHG4Sc/+UmZZV1LyKqq9JOqcC2hvoh/V/33v/9N1oqLJk+enPyNtc0224QWLVok9//0pz8Nr732Wmjbtm349a9/nUw3WtraV+t6LVm6dOla0yEWrTOO9oltqkgc6VR0LUp9hKwQCsF6EC+M//u//xueeeaZ5IOLOAdx/FA7Tr1w8803JxfWQk899VQyL3HJRfhKLuxXmYtbaeVKLg5Ycr869ZasA3LVRwrFP+riH6f//Oc/kzriORr/YIx1x+np4qKsheLjBx54YDLncWn0EXI9pWLRKbLiNFjxHy5xPvs4ZUL8cGLEiBHJB9txDuw4DUnhP5LiwqorV64sVp/rCPmopvtJIdcSsiIu9h2nWiwU/85q2rRpmeVdS8iiqvaTQq4l5Js4FW8ML//2t7+Fli1bllkuTi03ePDgYoHL+ryWVLbOkmX1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Fi1apBejtm3bFnzzzTdl1nvJJZekZeNxDz74YLHHV65cWXDBBRcUuwCPGTOm3La++OKLxcrH42M9RcXnKdrO4cOHV+n9gNruI19++WXyB+A111xTMGXKlFLrW7NmTcFTTz2V/NFXWF+DBg0Knn766XLbqo+QS2effXZ6XsUP4f71r3+tVSb+f759+/Zpud122y0530vjOkI+qql+4lpCFgwdOjQ9x7p3716tOlxLyHfr0k9cS8g3q1atSvpB4TkVv7D597//veD7779f67yO51+bNm3Ssp07d07CmtIcd9xxabmNN9644KWXXir2+OLFi4uVadq0acFnn31WblvvvPPOtHwMneLnCUWD1rgd7ysaSMXPJCBLGsT/5Hq0EuSrOPz1xBNPTLYbN24cevTokdw22mijsHr16mShvjgEtugaKXGe1ueee67cqUxWrVoVDjzwwGJDcXfYYYfQp0+fsHz58vDKK6+EGTNmpI8NHz48XHrppRW295JLLglXXnllsXmM99xzz2SI/DvvvBM+/vjj9LH99tsvPPPMM8nrgrrSR6ZMmVJsXYktt9wy6RtxgcsmTZqEOXPmhDfeeCN88803xY774x//GM4777wK26uPkCsrVqxIzqlXX3212OKqu+yySzJ9wocffpicg4XiAqvxXO/cuXOp9bmOkI9qqp+4lpDvVq5cmZz/hWtCXHXVVeHiiy+ucj2uJeSzde0nriXko3jOxnV3li5dmt4X+0lcryqe2wsXLgyvv/56cv4XiutYxakO99hjj1LrjP/Wj8ePHz8+va9v376hV69eyWPxGjN//vxinyEMGTKkwrYef/zx4d577033u3Xrlq6HF9s4efLk9LH4mUTRqeohE3KdSkG+j4Io+g2eim677rprwYQJEypVdxxVceSRR5ZbX5xG5aqrrqp0e+M3Oq644opi06+UdotDdRcuXLgO7wzUTh+J38irSn1bbLFFuVMMlaSPkEvx//tFp+Ap69a3b9+CadOmVao+1xHyTU30E9cS8l0cRVf0G9RfffVVtetyLSFfrWs/cS0hn6dVLDpiqLzbD37wg4LXXnutwjqnT59eMGjQoHLratWqVcFdd91V6XbGqeD+53/+Jxl9V1ad8bGzzjprrZF2kAVGCkEtf2M1fktn7Nix6TcR5s6dG+bNmxfWrFkT2rRpk3x7KH5b4fDDDy/zmxPlid+4iItfxtEU8Zt48VtH8RuvBxxwQDj55JOTb8hW1cSJE8Mdd9wRnn/++WSxy/gtwPjtj379+iXfyNh3332rXCesjz4SL2nxG0axP8Q643ZhffHbTK1bt07O5fit8YMOOigceuihSZ+pKn2EXIrfvL7nnnvCa6+9loymi6PqOnTokPSTI488MlkgNS7cWlmuI+SjdeknriXku5///OfhySefTLbjefbCCy+sc52uJeSbde0nriXks++//z7pH48//nh4++23kxFvixcvDi1btkz+3vrRj34UDj744OTf8JU9r2Ofeeyxx8J9990X3n333eRa0qpVq9ClS5fws5/9LLmWxO2qevPNN5NRQGPGjEn+Joy22GKLMGDAgKTO2Achi4RCAAAAAAAAGdAw1w0AAAAAAACg9gmFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAgDqgQYMG6a0sW265ZVpmypQp67V9AABA/dc41w0AAACIFi1aFJ599tnwwgsvhLfffjvMmTMnzJ07NzRt2jRstNFGoXv37mGXXXYJBx98cOjXr1+um5t5q1evDs8880x47LHHkt/X9OnTk99hkyZNQps2bULXrl1Dz549k9/Z3nvvHbbbbrtcNxkAADKvQUFBQUGuGwEAAGTX0qVLw5///Odw3XXXhfnz51fqmBgQDRs2LBx99NHljqypT4q+jrL+mRZHCk2dOjXZ/vLLL5P9kk444YRw9913J9sjRoxI9mvauHHjwkknnRQmTZpU6WN+8pOfhKeeeqrG2wIAAFSekUIAAEDOTJs2LfzsZz8LH374YbH7u3TpEnbcccfQvn37ZETKzJkzwwcffBBmzZqVPP7pp5+GY445Jnz11Vfht7/9bY5an03/+c9/ws9//vOwYsWKYr+v3r17J7+vNWvWJCO84u+rMMCKFixYkKMWAwAAhYRCAABATsQ1ceI0cDHwKRwpM3jw4HDxxReXOtVYHD0Tpyn761//Gu67774kfIijjLIk1+sIffvtt+GXv/xlGgjF6eFuvvnmMGDAgFLLx9AuTi83cuTI9dxSAACgNA1LvRcAAKAWrVy5MhxxxBFpINS8efPw6KOPJmFPWWvPxNAork9zzz33JKNQtt9++/Xcau68885kFFDUoUOH8Morr5QZCEWdO3cOZ511Vnj33XfTKe0AAIDcMVIIAABY76699tpk1E+hGBgccsghlT4+BkKvv/56eP/992uphZTm+eefT7dPPPHEsMkmm1T62G7dutVSqwAAgMoyUggAAFivli1bFv7yl7+k+7/4xS/CkUceWeV6WrZsGXbfffe17o8jV+KoongbM2ZMct+MGTPC1VdfHXbdddew2WabhUaNGoW2bduWWu+qVavCvffem7Rpq622ChtuuGHyXD/4wQ+S6e3idGhxKrvKWrhwYfjDH/6QjHLaaKONQqtWrcK2224bTj311PDOO+9U6TVvueWW6WsrOZVc4WNFR+TE4KawfNHbsGHDQnVMnz493e7atWuoDcuXLw933XVX8v7HIKl169ahadOmYdNNNw177rlnuPDCC8Mbb7xRYT2LFy9OzrMDDjggdOrUKRmNFt//GCj+z//8T6XqiIq+b4XiSLWzzz47qatdu3bJY2WFmvPmzQt/+tOfwn777ZeMnIrtiOder169wplnnlksHAUAgNpmpBAAALBePfLII2HOnDnp/rnnnlurz/fEE08k4cj8+fMrLBtDpFNOOSVMnjx5rcdiCBNv//znP8Nuu+2WvI4tttii3Ppee+21cNRRR4Vvvvmm2P2ffvppcovhx2WXXRYuvfTSUB80bPj//17hl19+WeP1xykE43RzRcOnQvGcibf4nl5zzTXh73//ezjjjDNKreepp55KQrfC6QkLxbWQFixYEMaPHx9uuummcMwxx4Tbb789bLDBBpVuYwzUrrzyyrB69eoKy8bn+N3vfpcEgyXbEe+bOHFi8jri+Rl/xvALAABqk1AIAABYr0aPHp1ud+nSpdTRPjVl7NixyYf4cfTPxhtvHPbaa69kyrPZs2eH9957r1jZhx9+OBx77LFJ2ahFixZJ+BNH4MQwJIY448aNC99//30ydV2/fv3CW2+9laytU5o4Cuiggw5KRqwU2nnnncMOO+yQrKkU64jhUwyF4giWdTVkyJBkVMqoUaPCpEmTkvv22Wef0KNHj7XKxhFT1RFH7sRAJRo5cmQS6JX1+qsqjqY5//zz01FYcfTNjjvumKwxFUdXffvtt+Gjjz4Kn3zySTqiqDQPPvhg8nssDG3iqLA99tgjbL311snv4tVXX01Duvvvvz8Jt+I5GUfwVOSPf/xjGD58ePpexPcxBkoxLGzSpEmxsuecc0648cYb0/143sVzJo5Ui22P59/HH3+cvN4YDsY2Pf3008WCNwAAqHEFAAAA61G3bt3ip/7J7Ygjjqjx+vfee++0/saNGxc0aNCg4IorrihYuXJlsXLLly9Ptz/++OOCFi1aJMfE8uedd17B/Pnz16p78uTJBXvssUda/0EHHVRqG1asWFHQs2fPtFznzp0Lxo4du1a5u+++u6BZs2YFTZs2TcuW98+0rl27pmW+/PLLUssMGTIkLTNixIiCmnTXXXcVa2eXLl0KbrnlloK5c+euU71PP/108r4X1jto0KCCCRMmlFr2iy++KLjkkksKRo4cudZjn3/+eUGrVq3SenbdddeCzz77rFiZ1atXF/zpT38qaNiwYVru17/+dZltK/p64/nUpk2bgscee2ytckXPpzvvvDM9pnXr1gW33377WudfNHr06IItttgiLXvNNddU+F4BAMC6aBD/U/NREwAAQOniiIo42iaKo3jiSJmaFNcUevnll9P9ONVXnMKrPHFETeEIpuuvvz785je/KbPskiVLkhEiEyZMSPbjiJ++ffsWKxOnJDvttNOS7TgCJY4KKW3ETnTfffeFX/7yl8XuK+ufaXHU0tSpU5PtOMIl7pd0wgknpOsKjRgxItmvKXEUVRw99e677xa7P45uievrxPcljoaKI2LifmVGvcRzYZtttknXSPrpT3+arNvUuHHjao2Wuueee5LtODIortfTpk2bUsvecMMN6dSFsZ2ff/55sm5USUXXEorlXnrppWTEWVm+++67ZARcnKYuTgf3yiuvrHV+FBWnkOvTp08yeiiOZps2bVqVprMDAICqMC4dAABYbxYtWpQGQlHbtm1r9fk233zzcMEFF5Rb5oMPPkgDod69eyfTfpWnZcuW4ZJLLikW6pR0xx13pNu//vWvywyEojjVWf/+/UN9CfSeeeaZZDq2otasWRM+/PDD5HXHdX5++MMfJtOlxfWZSgZIJf3rX/9KA6H43sYgqzqBUAxh4tRxha699toyA6Ho7LPPTqamK2z/bbfdVuFzHH744eUGQlGcCi62JRo6dGi5gVDUs2fPJMyK4vR/zz33XIXtAACA6hIKAQAA600cRVFUXCumNsUP8SsKGGLIUWjw4MHFRoaUZdCgQen2a6+9ttZrjCNUCh1//PEV1lcYCtQHcQ2hOBIrrscTRw2VZf78+eHOO+9MRg7FcKisNYCKhiDx/Y9hUnXXj1qxYkWyHev42c9+Vm75OOrnpJNOSvfjCKCKHH300RWWKXo+HXPMMaEyyjufAACgJlX961cAAADVtOGGGxbbX7x4ca0+349+9KMKy4wbN65YMFA4PVt5ik7v9tVXXxV7LI6YiSNPCl9v4WiU8sTp1uqTGKjEACfe4usfM2ZMMo3eO++8k4y8KhoAxfcqhkNxurv//Oc/a4V08bhCAwcOrHab4hR9heI0dpUZbbT77rsXOz62tbxQsKrnUxx9VDiVX3m+/vrrMs8nAACoSUIhAABgvWndunXyYX3hFHKF02zVlvbt21dY5ptvvkm3n3322So/RxwRU9ScOXPS7c6dO1dq5FFcg6a+iq/xuOOOS25RHK0TRxLFQOTRRx9NA7Q4Rd9f/vKXdB2fQrNmzUq3t9pqq2q3o+j73rVr10odU3RNppUrVyajvOI5Wt3zKYacRUfDFZ1GsLrnEwAA1CTTxwEAAOtV0Q/sJ0yYUKvP1aJFiwrLLFy4cJ2eY/Xq1WWOftpggw0qVUdcSydfNGvWLOy///7hkUceSdYLKjpi58Ybb1yrfNEQZV2mEyz6vlf2/SxZruT0hlU9n9b1XIqKrrkFAAA1TSgEAACsV3vssUe6/cYbb4RcKxoMFI5sqeqtqKLBxtKlSyvVhiVLloR8dOihhxZbt2fatGnJrawpBddlOsGi73tl38+S5UpOb1hVJUOmb7/9tsrnUpyKDwAAaotQCAAAWK8GDRqUbsf1e8aOHZvT9nTo0CHdnjlz5jrXV3SKsbhWTMnQqDT5vI7MgQceWGx/xowZZb7/cd2hmnjfSwZPZZkyZUq63bRp03UOhdq2bZuMlKrJ8wkAAGqSUAgAAFivjjjiiLDJJpuk+9dff31O29O3b990+7///e8617fjjjuGhg3/759aixYtqtQUeePGjQs1pTJrGK1PzZs3L7ZfNDSJdtttt3Q7rjtUXb17906333zzzbWm9StN0UAyHl8T792uu+5ao+cTAADUJKEQAACwXsV1Wc4666x0P647E29VFaf+qolRRj/96U+LTR83a9asdaovjjbZeeed0/177723wmPuueeeUBshzKpVq0KuffDBB+l2DF06depU7PGDDjoo3f7nP/8Z5s6dW63n6d+/fxo4zZkzJzz99NPlll+zZk0YMWJEqSPYaup8+vvf/16pkWIAALC+CIUAAID17re//W3o06dPun/ccceFf//735U+/uOPP05GmDz//PM1MrJjwIAByfayZcuStqxcubJSx8Zy8+fPX+v+U045Jd3+y1/+Ej799NMy64hByGuvvRZqysYbb5xuT58+PdSkyy67LLz99tuVLj979uxw4403pvsxLCs6Siz6xS9+Ebp27ZquKXTiiSeG77//vlpTtx111FHp/vnnnx++++67Msv/7W9/Cx999FGyHUd2nXbaaaEmnH766UlbonfffTcMHz680sfGQKwyI5wAAKC6hEIAAMB6F0d0PPzww2HTTTdNw5hDDjkkHH/88WHixImlHhNHXLz11lthyJAh4Yc//GESDNWUv/71r6FVq1bJ9gsvvBD22muv8MYbb5RZPoY8V1xxRdhyyy1LnSIsvo5tt902fW377bdfqfXdd999SQgS17OpKdtvv326/cQTT1Q64KqM//znP2GXXXZJRtXE0U0LFiwo83cVA7vdd9+92Lo6F1100VplGzdunAQ0hVO3PfXUU+GAAw4IkyZNKnMdoEsvvbTU0VXx/sLfY/wdxXq++OKLtUYIxaDq3HPPTe8788wzk99lTWjTpk244YYb0v0YCsVztqx1juJ7Fc+hoUOHhi5duiTnCwAA1JYGBcayAwAAORI/4P/Zz362VsATP6CPa/PEUSVx5EQMFt5///21pna77rrrwv/+7/8Wuy+O+nn55ZeT7ZdeeikdBVSRGEbEkSZLly5N7+vWrVsyoqldu3Zh+fLlyciXDz/8sNgInDjCqeiUYYVigDVw4MBkmruio5JiaBODmtdffz18/vnn6WiiolPqlfXPtPi+TJ06Ndn+8ssvSw0yFi5cGDp27JiGC1tttVXyHsTRK4XBy/7775/cqiqOzioabsX6evbsmQRghSOU4u/onXfeCd98802xY3/9618nr7Ms11xzTbjwwguL1R3Dv+222y4Jer799tvkvf/kk0+Sx2Pwcs4556xVz4MPPhiOPfbYdMRNDJ323HPP5HcZRyK9+uqrxX5/8TXF86Tk2kdF21GoKv98jgFVDA4LNWrUKOy0006hR48eyeuJbfn666+T8zr+zgrF0U2FwRYAANQ0oRAAAJBT8cPx+AH/9ddfX+bIk5JiWDBs2LBkdFFJ1Q2FCte/Ofnkk5NQozJiKPPYY48lH/aX5pVXXkmCpqKjZYqK05ZdcsklyWupTPhQmVAouuWWW5KRJ2XVE6eBi89ZVTHkuPnmm8t8PaXZaKONwlVXXRV+9atfVVg2Bjpnn312pdZ1uu2228Kpp55aZsAXp/CrqJ7BgweHO+64I2ywwQZllqluKBQ99NBD4Te/+c1aAVlZYmgYz5nCtZEAAKCmCYUAAIA6IY6WeOaZZ5Lp22IoE0flxNEhcWq1OFInjrDo27dvEgQVXY+oJkOhQnHqs8cffzyZ1it+oB/DqvhBffv27ZNRMbEdcWqyfv36FQsNShOPvemmm8Kjjz4aJk+eHFatWhU233zzZIq6uP5MDAKimgyFotj2W2+9NRnZE0fGxBFQhfVWNxQqbFtcVyiGF2+++WYycieOeFm0aFHyGlq3bh06deqUjPSKo5Hi76tly5aVrj+OrIpTwz377LNJSDdnzpxk1E8Ml+J7v8cee4TDDz889O7du8Kw8a677koCovHjxyfr9bRo0SJ57+MIrjjFX/w9VmRdQqFoxYoVybpRceq9OHosvp7YtviebLHFFslIqziS6cc//nHo3r17lesHAICqEAoBAAAAAABkQMNcNwAAAAAAAIDaJxQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgAwQCgEAAAAAAGSAUAgAAAAAACADhEIAAAAAAAAZIBQCAAAAAADIAKEQAAAAAABABgiFAAAAAAAAMkAoBAAAAAAAkAFCIQAAAAAAgJD//j/0n7jmJqaNXwAAAABJRU5ErkJggg==",
"text/plain": [
""
]
@@ -438,10 +444,10 @@
"score_min, score_max = points_scores.min(), points_scores.max()\n",
"bins = np.linspace(score_min, score_max, 20)\n",
"\n",
- "# plot score hist based on y == 1\n",
- "ax.hist(points_scores[y == 1], bins=bins, color=\"blue\", alpha=0.5, label=\"Approved loans\")\n",
"# plot score hist based on y == 0\n",
- "ax.hist(points_scores[y == 0], bins=bins, color=\"red\", alpha=0.5, label=\"Declined loans\")\n",
+ "ax.hist(points_scores[y == 0], bins=bins, color=\"blue\", alpha=0.5, label=\"Approved loans\")\n",
+ "# plot score hist based on y == 1\n",
+ "ax.hist(points_scores[y == 1], bins=bins, color=\"red\", alpha=0.5, label=\"Declined loans\")\n",
"ax.set_xlabel(\"Credit Score\")\n",
"ax.set_ylabel(\"Frequency\")\n",
"ax.set_title(\"Credit Score Distribution\")\n",
@@ -451,7 +457,7 @@
},
{
"cell_type": "code",
- "execution_count": 4,
+ "execution_count": 5,
"metadata": {},
"outputs": [
{
@@ -554,8 +560,8 @@
"\n",
"\n",
"7 \n",
- " \n",
- "val = -0.081 \n",
+ " \n",
+ "val = 0.081 \n",
" \n",
"\n",
"\n",
@@ -567,8 +573,8 @@
"\n",
"\n",
"8 \n",
- " \n",
- "val = 0.160 \n",
+ " \n",
+ "val = -0.160 \n",
" \n",
"\n",
"\n",
@@ -580,89 +586,89 @@
"\n",
"\n",
"9 \n",
- " \n",
- "val = -0.055 \n",
+ " \n",
+ "val = 0.055 \n",
" \n",
"\n",
"\n",
"4->9 \n",
- " \n",
- " \n",
- "No \n",
+ " \n",
+ " \n",
+ "No \n",
" \n",
"\n",
"\n",
"10 \n",
- " \n",
- "val = 0.187 \n",
+ " \n",
+ "val = -0.187 \n",
" \n",
"\n",
"\n",
"4->10 \n",
- " \n",
- " \n",
- "Yes \n",
+ " \n",
+ " \n",
+ "Yes \n",
" \n",
"\n",
"\n",
"11 \n",
- " \n",
- "val = 0.000 \n",
+ " \n",
+ "val = 0.000 \n",
" \n",
"\n",
"\n",
"5->11 \n",
- " \n",
- " \n",
- "No \n",
+ " \n",
+ " \n",
+ "No \n",
" \n",
"\n",
"\n",
"12 \n",
- " \n",
- "val = 0.025 \n",
+ " \n",
+ "val = -0.025 \n",
" \n",
"\n",
"\n",
"5->12 \n",
- " \n",
- " \n",
- "Yes \n",
+ " \n",
+ " \n",
+ "Yes \n",
" \n",
"\n",
"\n",
"13 \n",
- " \n",
- "val = 0.000 \n",
+ " \n",
+ "val = 0.000 \n",
" \n",
"\n",
"\n",
"6->13 \n",
- " \n",
- " \n",
- "No \n",
+ " \n",
+ " \n",
+ "No \n",
" \n",
"\n",
"\n",
"14 \n",
- " \n",
- "val = 0.067 \n",
+ " \n",
+ "val = -0.067 \n",
" \n",
"\n",
"\n",
"6->14 \n",
- " \n",
- " \n",
- "Yes \n",
+ " \n",
+ " \n",
+ "Yes \n",
" \n",
" \n",
"\n"
],
"text/plain": [
- ""
+ ""
]
},
- "execution_count": 4,
+ "execution_count": 5,
"metadata": {},
"output_type": "execute_result"
}
@@ -680,7 +686,7 @@
},
{
"cell_type": "code",
- "execution_count": 5,
+ "execution_count": 6,
"metadata": {},
"outputs": [
{
@@ -692,7 +698,7 @@
"CatBoost: 0.8958\n",
"Raw Scores: 0.8957\n",
"WOE Scores: 0.8957\n",
- "Points Scores: 0.8944\n",
+ "Points Scores: -0.8936\n",
"\n",
"Feature Importance:\n",
"Application_Score: 0.5113\n",
@@ -747,7 +753,7 @@
},
{
"cell_type": "code",
- "execution_count": 6,
+ "execution_count": 7,
"metadata": {},
"outputs": [
{
diff --git a/examples/finetuning-getting-started.ipynb b/examples/finetuning-getting-started.ipynb
new file mode 100644
index 0000000..8212ea1
--- /dev/null
+++ b/examples/finetuning-getting-started.ipynb
@@ -0,0 +1,1218 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# xbooster \n",
+ "\n",
+ "## Fine-Tuning - Getting started \n",
+ "\n",
+ "Repo: https://github.com/xRiskLab/xBooster \n",
+ "\n",
+ "This notebook demonstrates how to fine-tune gradient boosted models and build scorecards\n",
+ "that distinguish between base and fine-tuned trees.\n",
+ "\n",
+ "**Workflow:**\n",
+ "\n",
+ "1. Train base models (XGBoost, LightGBM, CatBoost)\n",
+ "2. Fine-tune with `finetune_*` helpers\n",
+ "3. Build scorecards with `from_finetune_result()` or `n_base_trees`\n",
+ "4. Inspect `TreeSource` column and `summarize_score_sources()`\n",
+ "5. Score and compare distributions\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:11.837086Z",
+ "iopub.status.busy": "2026-04-19T12:37:11.836602Z",
+ "iopub.status.idle": "2026-04-19T12:37:14.470042Z",
+ "shell.execute_reply": "2026-04-19T12:37:14.468686Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Train: 350, Test: 150, Event rate: 6.40%\n"
+ ]
+ }
+ ],
+ "source": [
+ "import numpy as np\n",
+ "import pandas as pd\n",
+ "from sklearn.model_selection import train_test_split\n",
+ "\n",
+ "# Generate synthetic credit data with ~25% default rate\n",
+ "np.random.seed(42)\n",
+ "n = 500\n",
+ "data = pd.DataFrame(\n",
+ " {\n",
+ " \"income\": np.random.lognormal(10.5, 0.5, n),\n",
+ " \"debt_ratio\": np.random.beta(2, 5, n),\n",
+ " \"credit_age\": np.random.exponential(5, n),\n",
+ " }\n",
+ ")\n",
+ "# Create a signal strong enough for the models to learn meaningful splits\n",
+ "logit = -1 + 3 * data[\"debt_ratio\"] - 0.0001 * data[\"income\"] + 0.1 * data[\"credit_age\"]\n",
+ "data[\"default\"] = (np.random.rand(n) < 1 / (1 + np.exp(-logit))).astype(int)\n",
+ "\n",
+ "X = data[[\"income\", \"debt_ratio\", \"credit_age\"]]\n",
+ "y = data[\"default\"]\n",
+ "\n",
+ "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.3, random_state=42)\n",
+ "print(f\"Train: {len(X_train)}, Test: {len(X_test)}, Event rate: {y.mean():.2%}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 1. Train Base Models\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:14.518745Z",
+ "iopub.status.busy": "2026-04-19T12:37:14.518074Z",
+ "iopub.status.idle": "2026-04-19T12:37:15.043261Z",
+ "shell.execute_reply": "2026-04-19T12:37:15.041634Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Base models trained.\n"
+ ]
+ }
+ ],
+ "source": [
+ "from xgboost import XGBClassifier\n",
+ "from lightgbm import LGBMClassifier\n",
+ "from catboost import CatBoostClassifier\n",
+ "\n",
+ "# XGBoost base model\n",
+ "xgb_base = XGBClassifier(\n",
+ " n_estimators=10, max_depth=1, learning_rate=0.3, random_state=42, eval_metric=\"logloss\"\n",
+ ")\n",
+ "xgb_base.fit(X_train, y_train)\n",
+ "\n",
+ "# LightGBM base model\n",
+ "lgb_base = LGBMClassifier(\n",
+ " n_estimators=10, max_depth=1, learning_rate=0.3, random_state=42, verbose=-1\n",
+ ")\n",
+ "lgb_base.fit(X_train, y_train)\n",
+ "\n",
+ "# CatBoost base model\n",
+ "cb_base = CatBoostClassifier(iterations=10, depth=1, learning_rate=0.3, random_seed=42, verbose=0)\n",
+ "cb_base.fit(X_train, y_train)\n",
+ "\n",
+ "print(\"Base models trained.\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 2. Fine-Tune with Same Features\n",
+ "\n",
+ "When fine-tuning with the same feature set, base trees are frozen and new trees are appended.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:15.051709Z",
+ "iopub.status.busy": "2026-04-19T12:37:15.050994Z",
+ "iopub.status.idle": "2026-04-19T12:37:15.754107Z",
+ "shell.execute_reply": "2026-04-19T12:37:15.753042Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XGBoost: 10 base + 5 new = 15 total trees\n",
+ "Base features: ['income', 'debt_ratio', 'credit_age']\n",
+ "New features: []\n",
+ "LightGBM: 10 base + 5 new = 15 total trees\n",
+ "Base features: ['income', 'debt_ratio', 'credit_age']\n",
+ "New features: []\n",
+ "CatBoost: 10 base + 5 new = 15 total trees\n",
+ "Base features: ['income', 'debt_ratio', 'credit_age']\n",
+ "New features: []\n"
+ ]
+ }
+ ],
+ "source": [
+ "from xbooster.finetuner import finetune_xgb, finetune_lgb, finetune_cb\n",
+ "from xbooster.xgb_constructor import XGBScorecardConstructor\n",
+ "from xbooster.lgb_constructor import LGBScorecardConstructor\n",
+ "from xbooster.cb_constructor import CBScorecardConstructor\n",
+ "\n",
+ "# Same features: base trees are frozen, new trees appended\n",
+ "xgb_ft = finetune_xgb(xgb_base, X_train, y_train, n_estimators=5, learning_rate=0.1)\n",
+ "lgb_ft = finetune_lgb(lgb_base, X_train, y_train, n_estimators=5, learning_rate=0.1, verbose=-1)\n",
+ "cb_ft = finetune_cb(cb_base, X_train, y_train, n_estimators=5, learning_rate=0.1, verbose=0)\n",
+ "\n",
+ "for name, ft in [(\"XGBoost\", xgb_ft), (\"LightGBM\", lgb_ft), (\"CatBoost\", cb_ft)]:\n",
+ " print(\n",
+ " f\"{name}: {ft.n_base_trees} base + {ft.n_total_trees - ft.n_base_trees} new = {ft.n_total_trees} total trees\"\n",
+ " )\n",
+ " print(f\"Base features: {ft.base_features}\")\n",
+ " print(f\"New features: {ft.new_features}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 3. Fine-Tune with Expanded Features (New Data Sources)\n",
+ "\n",
+ "In production, new data sources often become available quarterly. For example, a bureau\n",
+ "adds `num_inquiries` or a new internal feature `savings_ratio` is engineered.\n",
+ "\n",
+ "When new features are added, native continued training isn't possible (the base model\n",
+ "doesn't know about the new columns). Instead, `finetune_*` uses a **warm-start** approach:\n",
+ "the base model's raw predictions become the starting point for a fresh model trained on\n",
+ "all features. This means `n_base_trees=0` (no base trees carried over), but the base\n",
+ "model's knowledge is preserved via initial scores.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:15.759280Z",
+ "iopub.status.busy": "2026-04-19T12:37:15.758882Z",
+ "iopub.status.idle": "2026-04-19T12:37:15.815029Z",
+ "shell.execute_reply": "2026-04-19T12:37:15.813870Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Updated event rate: 2.86%\n",
+ "\n",
+ "Base features: ['income', 'debt_ratio', 'credit_age']\n",
+ "New features: ['num_inquiries', 'savings_ratio']\n",
+ "All features: ['income', 'debt_ratio', 'credit_age', 'num_inquiries', 'savings_ratio']\n",
+ "Base trees: 0 (warm-start: no base trees carried over)\n",
+ "Total trees: 50\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Simulate new data arriving with 2 additional features that have predictive signal\n",
+ "np.random.seed(99)\n",
+ "X_train_expanded = X_train.copy()\n",
+ "X_train_expanded[\"num_inquiries\"] = np.random.exponential(3, len(X_train))\n",
+ "X_train_expanded[\"savings_ratio\"] = np.random.beta(5, 2, len(X_train))\n",
+ "\n",
+ "X_test_expanded = X_test.copy()\n",
+ "X_test_expanded[\"num_inquiries\"] = np.random.exponential(3, len(X_test))\n",
+ "X_test_expanded[\"savings_ratio\"] = np.random.beta(5, 2, len(X_test))\n",
+ "\n",
+ "# Augment the target: more inquiries and lower savings increase default risk\n",
+ "np.random.seed(99)\n",
+ "logit_new = (\n",
+ " -2\n",
+ " + 3 * X_train_expanded[\"debt_ratio\"]\n",
+ " - 0.0001 * X_train_expanded[\"income\"]\n",
+ " + 0.8 * X_train_expanded[\"num_inquiries\"]\n",
+ " - 2 * X_train_expanded[\"savings_ratio\"]\n",
+ ")\n",
+ "y_train_new = pd.Series(\n",
+ " (np.random.rand(len(X_train_expanded)) < 1 / (1 + np.exp(-logit_new))).astype(int),\n",
+ " index=y_train.index,\n",
+ ")\n",
+ "print(f\"Updated event rate: {y_train_new.mean():.2%}\")\n",
+ "\n",
+ "# Fine-tune XGBoost with expanded features (more trees to let all features surface)\n",
+ "xgb_ft_expanded = finetune_xgb(\n",
+ " xgb_base, X_train_expanded, y_train_new, n_estimators=50, learning_rate=0.1\n",
+ ")\n",
+ "\n",
+ "print(f\"\\nBase features: {xgb_ft_expanded.base_features}\")\n",
+ "print(f\"New features: {xgb_ft_expanded.new_features}\")\n",
+ "print(f\"All features: {xgb_ft_expanded.all_features}\")\n",
+ "print(f\"Base trees: {xgb_ft_expanded.n_base_trees} (warm-start: no base trees carried over)\")\n",
+ "print(f\"Total trees: {xgb_ft_expanded.n_total_trees}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:15.819381Z",
+ "iopub.status.busy": "2026-04-19T12:37:15.818995Z",
+ "iopub.status.idle": "2026-04-19T12:37:15.884814Z",
+ "shell.execute_reply": "2026-04-19T12:37:15.883637Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Features in expanded scorecard: ['credit_age', 'income', 'num_inquiries', 'savings_ratio']\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " Tree \n",
+ " Feature \n",
+ " Sign \n",
+ " Split \n",
+ " XAddEvidence \n",
+ " IV \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 0 \n",
+ " num_inquiries \n",
+ " < \n",
+ " 9.180827 \n",
+ " -0.107105 \n",
+ " 1.959988 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 0 \n",
+ " num_inquiries \n",
+ " >= \n",
+ " 9.180827 \n",
+ " 0.316632 \n",
+ " 2.800504 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 1 \n",
+ " num_inquiries \n",
+ " < \n",
+ " 9.180827 \n",
+ " -0.104820 \n",
+ " 1.959988 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 1 \n",
+ " num_inquiries \n",
+ " >= \n",
+ " 9.180827 \n",
+ " 0.262875 \n",
+ " 2.800504 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 2 \n",
+ " num_inquiries \n",
+ " < \n",
+ " 9.180827 \n",
+ " -0.102628 \n",
+ " 1.959988 \n",
+ " \n",
+ " \n",
+ " ... \n",
+ " ... \n",
+ " ... \n",
+ " ... \n",
+ " ... \n",
+ " ... \n",
+ " ... \n",
+ " \n",
+ " \n",
+ " 95 \n",
+ " 47 \n",
+ " savings_ratio \n",
+ " >= \n",
+ " 0.776701 \n",
+ " -0.059653 \n",
+ " 0.752626 \n",
+ " \n",
+ " \n",
+ " 96 \n",
+ " 48 \n",
+ " credit_age \n",
+ " < \n",
+ " 10.023369 \n",
+ " -0.023756 \n",
+ " 0.035995 \n",
+ " \n",
+ " \n",
+ " 97 \n",
+ " 48 \n",
+ " credit_age \n",
+ " >= \n",
+ " 10.023369 \n",
+ " 0.054828 \n",
+ " 0.137187 \n",
+ " \n",
+ " \n",
+ " 98 \n",
+ " 49 \n",
+ " num_inquiries \n",
+ " < \n",
+ " 9.180827 \n",
+ " -0.041987 \n",
+ " 1.959988 \n",
+ " \n",
+ " \n",
+ " 99 \n",
+ " 49 \n",
+ " num_inquiries \n",
+ " >= \n",
+ " 9.180827 \n",
+ " 0.028370 \n",
+ " 2.800504 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
100 rows × 6 columns
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Tree Feature Sign Split XAddEvidence IV\n",
+ "0 0 num_inquiries < 9.180827 -0.107105 1.959988\n",
+ "1 0 num_inquiries >= 9.180827 0.316632 2.800504\n",
+ "2 1 num_inquiries < 9.180827 -0.104820 1.959988\n",
+ "3 1 num_inquiries >= 9.180827 0.262875 2.800504\n",
+ "4 2 num_inquiries < 9.180827 -0.102628 1.959988\n",
+ ".. ... ... ... ... ... ...\n",
+ "95 47 savings_ratio >= 0.776701 -0.059653 0.752626\n",
+ "96 48 credit_age < 10.023369 -0.023756 0.035995\n",
+ "97 48 credit_age >= 10.023369 0.054828 0.137187\n",
+ "98 49 num_inquiries < 9.180827 -0.041987 1.959988\n",
+ "99 49 num_inquiries >= 9.180827 0.028370 2.800504\n",
+ "\n",
+ "[100 rows x 6 columns]"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Build scorecard from expanded-feature model\n",
+ "xgb_sc_expanded = XGBScorecardConstructor(xgb_ft_expanded.model, X_train_expanded, y_train_new)\n",
+ "expanded_scorecard = xgb_sc_expanded.construct_scorecard()\n",
+ "\n",
+ "# The scorecard now includes the new features\n",
+ "print(\"Features in expanded scorecard:\", sorted(expanded_scorecard[\"Feature\"].unique()))\n",
+ "expanded_scorecard[[\"Tree\", \"Feature\", \"Sign\", \"Split\", \"XAddEvidence\", \"IV\"]]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 4. Build Scorecards with `from_finetune_result()`\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:15.890112Z",
+ "iopub.status.busy": "2026-04-19T12:37:15.889619Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.017717Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.015886Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XGBoost scorecard columns: ['Tree', 'Node', 'Feature', 'Sign', 'Split', 'Count', 'CountPct', 'NonEvents', 'Events', 'EventRate', 'WOE', 'IV', 'XAddEvidence', 'DetailedSplit', 'TreeSource']\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " Tree \n",
+ " Feature \n",
+ " Sign \n",
+ " Split \n",
+ " XAddEvidence \n",
+ " IV \n",
+ " TreeSource \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 0 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.381748 \n",
+ " 0.549239 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 0 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.277945 \n",
+ " 1.582489 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 1 \n",
+ " income \n",
+ " < \n",
+ " 24092.572300 \n",
+ " 0.343839 \n",
+ " 0.633282 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 1 \n",
+ " income \n",
+ " >= \n",
+ " 24092.572300 \n",
+ " -0.166161 \n",
+ " 0.436613 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 2 \n",
+ " credit_age \n",
+ " < \n",
+ " 2.341394 \n",
+ " -0.295999 \n",
+ " 1.178644 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 5 \n",
+ " 2 \n",
+ " credit_age \n",
+ " >= \n",
+ " 2.341394 \n",
+ " 0.170014 \n",
+ " 0.202889 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 6 \n",
+ " 3 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.144004 \n",
+ " 0.549239 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 7 \n",
+ " 3 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.253732 \n",
+ " 1.582489 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 8 \n",
+ " 4 \n",
+ " credit_age \n",
+ " < \n",
+ " 2.341394 \n",
+ " -0.284740 \n",
+ " 1.178644 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 9 \n",
+ " 4 \n",
+ " credit_age \n",
+ " >= \n",
+ " 2.341394 \n",
+ " 0.107195 \n",
+ " 0.202889 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 10 \n",
+ " 5 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.094093 \n",
+ " 0.549239 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 11 \n",
+ " 5 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.238498 \n",
+ " 1.582489 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 12 \n",
+ " 6 \n",
+ " credit_age \n",
+ " < \n",
+ " 2.341394 \n",
+ " -0.272110 \n",
+ " 1.178644 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 13 \n",
+ " 6 \n",
+ " credit_age \n",
+ " >= \n",
+ " 2.341394 \n",
+ " 0.073840 \n",
+ " 0.202889 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 14 \n",
+ " 7 \n",
+ " income \n",
+ " < \n",
+ " 18214.062500 \n",
+ " 0.281985 \n",
+ " 0.567812 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 15 \n",
+ " 7 \n",
+ " income \n",
+ " >= \n",
+ " 18214.062500 \n",
+ " -0.090022 \n",
+ " 0.127559 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 16 \n",
+ " 8 \n",
+ " debt_ratio \n",
+ " < \n",
+ " 0.181397 \n",
+ " -0.238959 \n",
+ " 0.434870 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 17 \n",
+ " 8 \n",
+ " debt_ratio \n",
+ " >= \n",
+ " 0.181397 \n",
+ " 0.069943 \n",
+ " 0.063914 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 18 \n",
+ " 9 \n",
+ " credit_age \n",
+ " < \n",
+ " 2.341394 \n",
+ " -0.256726 \n",
+ " 1.178644 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 19 \n",
+ " 9 \n",
+ " credit_age \n",
+ " >= \n",
+ " 2.341394 \n",
+ " 0.056681 \n",
+ " 0.202889 \n",
+ " base \n",
+ " \n",
+ " \n",
+ " 20 \n",
+ " 10 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.021850 \n",
+ " 0.549239 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 21 \n",
+ " 10 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.072105 \n",
+ " 1.582489 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 22 \n",
+ " 11 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.019521 \n",
+ " 0.549239 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 23 \n",
+ " 11 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.070059 \n",
+ " 1.582489 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 24 \n",
+ " 12 \n",
+ " income \n",
+ " < \n",
+ " 32296.250000 \n",
+ " 0.017466 \n",
+ " 0.549239 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 25 \n",
+ " 12 \n",
+ " income \n",
+ " >= \n",
+ " 32296.250000 \n",
+ " -0.068000 \n",
+ " 1.582489 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 26 \n",
+ " 13 \n",
+ " credit_age \n",
+ " < \n",
+ " 2.341394 \n",
+ " -0.080901 \n",
+ " 1.178644 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 27 \n",
+ " 13 \n",
+ " credit_age \n",
+ " >= \n",
+ " 2.341394 \n",
+ " 0.013964 \n",
+ " 0.202889 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 28 \n",
+ " 14 \n",
+ " debt_ratio \n",
+ " < \n",
+ " 0.181397 \n",
+ " -0.072119 \n",
+ " 0.434870 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ " 29 \n",
+ " 14 \n",
+ " debt_ratio \n",
+ " >= \n",
+ " 0.181397 \n",
+ " 0.016042 \n",
+ " 0.063914 \n",
+ " finetuned \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Tree Feature Sign Split XAddEvidence IV TreeSource\n",
+ "0 0 income < 32296.250000 0.381748 0.549239 base\n",
+ "1 0 income >= 32296.250000 -0.277945 1.582489 base\n",
+ "2 1 income < 24092.572300 0.343839 0.633282 base\n",
+ "3 1 income >= 24092.572300 -0.166161 0.436613 base\n",
+ "4 2 credit_age < 2.341394 -0.295999 1.178644 base\n",
+ "5 2 credit_age >= 2.341394 0.170014 0.202889 base\n",
+ "6 3 income < 32296.250000 0.144004 0.549239 base\n",
+ "7 3 income >= 32296.250000 -0.253732 1.582489 base\n",
+ "8 4 credit_age < 2.341394 -0.284740 1.178644 base\n",
+ "9 4 credit_age >= 2.341394 0.107195 0.202889 base\n",
+ "10 5 income < 32296.250000 0.094093 0.549239 base\n",
+ "11 5 income >= 32296.250000 -0.238498 1.582489 base\n",
+ "12 6 credit_age < 2.341394 -0.272110 1.178644 base\n",
+ "13 6 credit_age >= 2.341394 0.073840 0.202889 base\n",
+ "14 7 income < 18214.062500 0.281985 0.567812 base\n",
+ "15 7 income >= 18214.062500 -0.090022 0.127559 base\n",
+ "16 8 debt_ratio < 0.181397 -0.238959 0.434870 base\n",
+ "17 8 debt_ratio >= 0.181397 0.069943 0.063914 base\n",
+ "18 9 credit_age < 2.341394 -0.256726 1.178644 base\n",
+ "19 9 credit_age >= 2.341394 0.056681 0.202889 base\n",
+ "20 10 income < 32296.250000 0.021850 0.549239 finetuned\n",
+ "21 10 income >= 32296.250000 -0.072105 1.582489 finetuned\n",
+ "22 11 income < 32296.250000 0.019521 0.549239 finetuned\n",
+ "23 11 income >= 32296.250000 -0.070059 1.582489 finetuned\n",
+ "24 12 income < 32296.250000 0.017466 0.549239 finetuned\n",
+ "25 12 income >= 32296.250000 -0.068000 1.582489 finetuned\n",
+ "26 13 credit_age < 2.341394 -0.080901 1.178644 finetuned\n",
+ "27 13 credit_age >= 2.341394 0.013964 0.202889 finetuned\n",
+ "28 14 debt_ratio < 0.181397 -0.072119 0.434870 finetuned\n",
+ "29 14 debt_ratio >= 0.181397 0.016042 0.063914 finetuned"
+ ]
+ },
+ "execution_count": 6,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# Build scorecards from same-feature fine-tune results\n",
+ "xgb_sc = XGBScorecardConstructor.from_finetune_result(xgb_ft, X_train, y_train)\n",
+ "lgb_sc = LGBScorecardConstructor.from_finetune_result(lgb_ft, X_train, y_train)\n",
+ "cb_sc = CBScorecardConstructor.from_finetune_result(cb_ft, X_train, y_train)\n",
+ "\n",
+ "# Construct and display XGBoost scorecard\n",
+ "xgb_scorecard = xgb_sc.construct_scorecard()\n",
+ "print(\"XGBoost scorecard columns:\", list(xgb_scorecard.columns))\n",
+ "xgb_scorecard[[\"Tree\", \"Feature\", \"Sign\", \"Split\", \"XAddEvidence\", \"IV\", \"TreeSource\"]]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 5. TreeSource Column\n",
+ "\n",
+ "The `TreeSource` column identifies which trees came from the base model vs. fine-tuning.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 7,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:16.024637Z",
+ "iopub.status.busy": "2026-04-19T12:37:16.023808Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.069817Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.068524Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XGBoost TreeSource distribution:\n",
+ "TreeSource\n",
+ "base 20\n",
+ "finetuned 10\n",
+ "Name: count, dtype: int64\n",
+ "\n",
+ "LightGBM TreeSource distribution:\n",
+ "TreeSource\n",
+ "base 20\n",
+ "finetuned 10\n",
+ "Name: count, dtype: int64\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Show tree source distribution\n",
+ "print(\"XGBoost TreeSource distribution:\")\n",
+ "print(xgb_scorecard[\"TreeSource\"].value_counts())\n",
+ "print()\n",
+ "\n",
+ "lgb_scorecard = lgb_sc.construct_scorecard()\n",
+ "print(\"LightGBM TreeSource distribution:\")\n",
+ "print(lgb_scorecard[\"TreeSource\"].value_counts())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 6. Summarize Score Sources\n",
+ "\n",
+ "See how Information Value is split between base and fine-tuned trees.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 8,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:16.074142Z",
+ "iopub.status.busy": "2026-04-19T12:37:16.073760Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.163253Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.162073Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XGBoost IV by Source\n",
+ " Feature BaseIV FinetunedIV TotalIV\n",
+ "0 credit_age 5.526132 1.381533 6.907665\n",
+ "1 debt_ratio 0.498784 0.498784 0.997568\n",
+ "2 income 8.160451 6.395184 14.555635\n",
+ "\n",
+ "LightGBM IV by Source\n",
+ " Feature BaseIV FinetunedIV TotalIV\n",
+ "0 credit_age 5.142833 1.468016 6.610850\n",
+ "1 debt_ratio 0.488630 0.488630 0.977260\n",
+ "2 income 9.147110 2.073578 11.220688\n",
+ "\n",
+ "CatBoost IV by Source\n",
+ " Feature BaseIV FinetunedIV TotalIV\n",
+ "0 credit_age 0.383577 0.625873 1.009450\n",
+ "1 debt_ratio 0.116040 0.000000 0.116040\n",
+ "2 income 1.042433 0.641951 1.684384\n"
+ ]
+ }
+ ],
+ "source": [
+ "# IV contribution by source\n",
+ "print(\"XGBoost IV by Source\")\n",
+ "print(xgb_sc.summarize_score_sources())\n",
+ "\n",
+ "print(\"\\nLightGBM IV by Source\")\n",
+ "print(lgb_sc.summarize_score_sources())\n",
+ "\n",
+ "print(\"\\nCatBoost IV by Source\")\n",
+ "print(cb_sc.summarize_score_sources())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 7. Score Comparison: Before vs. After Fine-Tuning\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 9,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:16.167655Z",
+ "iopub.status.busy": "2026-04-19T12:37:16.167003Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.245376Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.244444Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Score distribution comparison:\n"
+ ]
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ " Base Score Fine-tuned Score Actual\n",
+ "count 150.0 150.0 150.0\n",
+ "mean 457.5 482.0 0.1\n",
+ "std 80.9 88.9 0.2\n",
+ "min 290.0 300.0 0.0\n",
+ "25% 423.2 442.0 0.0\n",
+ "50% 455.0 482.0 0.0\n",
+ "75% 486.0 510.0 0.0\n",
+ "max 587.0 625.0 1.0\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Create points and predict scores\n",
+ "xgb_sc.create_points(pdo=50, target_points=600, target_odds=19)\n",
+ "scores_finetuned = xgb_sc.predict_score(X_test)\n",
+ "\n",
+ "# Compare with base model scores\n",
+ "xgb_base_sc = XGBScorecardConstructor(xgb_base, X_train, y_train)\n",
+ "xgb_base_sc.construct_scorecard()\n",
+ "xgb_base_sc.create_points(pdo=50, target_points=600, target_odds=19)\n",
+ "scores_base = xgb_base_sc.predict_score(X_test)\n",
+ "\n",
+ "comparison = pd.DataFrame(\n",
+ " {\n",
+ " \"Base Score\": scores_base.values,\n",
+ " \"Fine-tuned Score\": scores_finetuned.values,\n",
+ " \"Actual\": y_test.values,\n",
+ " }\n",
+ ")\n",
+ "print(\"Score distribution comparison:\")\n",
+ "print(comparison.describe().round(1))"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 8. SHAP Scoring with Fine-Tuned Models\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 10,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:16.249559Z",
+ "iopub.status.busy": "2026-04-19T12:37:16.249185Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.271312Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.269763Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "SHAP score summary:\n",
+ "count 150.0\n",
+ "mean 435.6\n",
+ "std 89.9\n",
+ "min 252.0\n",
+ "25% 395.0\n",
+ "50% 436.0\n",
+ "75% 464.0\n",
+ "max 580.0\n",
+ "Name: score, dtype: float64\n",
+ "\n",
+ "SHAP feature decomposition (first 5 rows):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " income_score \n",
+ " debt_ratio_score \n",
+ " credit_age_score \n",
+ " score \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 214 \n",
+ " 124 \n",
+ " 98 \n",
+ " 436 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 214 \n",
+ " 152 \n",
+ " 214 \n",
+ " 580 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 214 \n",
+ " 124 \n",
+ " 98 \n",
+ " 436 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 94 \n",
+ " 152 \n",
+ " 98 \n",
+ " 344 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 214 \n",
+ " 152 \n",
+ " 98 \n",
+ " 464 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " income_score debt_ratio_score credit_age_score score\n",
+ "0 214 124 98 436\n",
+ "1 214 152 214 580\n",
+ "2 214 124 98 436\n",
+ "3 94 152 98 344\n",
+ "4 214 152 98 464"
+ ]
+ },
+ "execution_count": 10,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "# SHAP-based scoring works with fine-tuned models\n",
+ "shap_scores = xgb_sc.predict_score(X_test, method=\"shap\")\n",
+ "shap_decomposition = xgb_sc.predict_scores(X_test, method=\"shap\")\n",
+ "\n",
+ "print(\"SHAP score summary:\")\n",
+ "print(shap_scores.describe().round(1))\n",
+ "print(\"\\nSHAP feature decomposition (first 5 rows):\")\n",
+ "shap_decomposition.head()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## 9. Alternative: Using `n_base_trees` Directly\n",
+ "\n",
+ "You can also pass `n_base_trees` directly to the constructor without using `from_finetune_result()`.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {
+ "execution": {
+ "iopub.execute_input": "2026-04-19T12:37:16.277066Z",
+ "iopub.status.busy": "2026-04-19T12:37:16.276608Z",
+ "iopub.status.idle": "2026-04-19T12:37:16.316111Z",
+ "shell.execute_reply": "2026-04-19T12:37:16.315106Z"
+ }
+ },
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "TreeSource from manual n_base_trees:\n",
+ "TreeSource\n",
+ "base 20\n",
+ "finetuned 10\n",
+ "Name: count, dtype: int64\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Direct construction with n_base_trees\n",
+ "manual_sc = XGBScorecardConstructor(xgb_ft.model, X_train, y_train, n_base_trees=10)\n",
+ "manual_scorecard = manual_sc.construct_scorecard()\n",
+ "print(\"TreeSource from manual n_base_trees:\")\n",
+ "print(manual_scorecard[\"TreeSource\"].value_counts())"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv (3.10.16)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.16"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/ims/xbooster.png b/examples/ims/xbooster.png
new file mode 100644
index 0000000..5d572cb
Binary files /dev/null and b/examples/ims/xbooster.png differ
diff --git a/examples/shap-in-leaf-weights.ipynb b/examples/shap-in-leaf-weights.ipynb
new file mode 100644
index 0000000..f8d01ef
--- /dev/null
+++ b/examples/shap-in-leaf-weights.ipynb
@@ -0,0 +1,348 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "4081ebea",
+ "metadata": {},
+ "source": [
+ "# xBooster \n",
+ "\n",
+ "## XAddEvidence and Feature SHAP Equivalence \n",
+ "\n",
+ "Repo: https://github.com/xRiskLab/xBooster \n",
+ "\n",
+ "This notebook demonstrates that XAddEvidence (per-tree margins) equals Feature SHAP (per-feature)\n",
+ "when using consistent base values. See docs/shap_scorecards.md for details.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "4d1f727f",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import numpy as np\n",
+ "import pandas as pd\n",
+ "import xgboost as xgb\n",
+ "from sklearn.model_selection import train_test_split\n",
+ "\n",
+ "from xbooster.shap_scorecard import extract_shap_values_xgb\n",
+ "from xbooster.xgb_constructor import XGBScorecardConstructor"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "6c16d065",
+ "metadata": {},
+ "source": [
+ "## Generate Sample Data\n",
+ "\n",
+ "We'll create a synthetic credit risk dataset for demonstration.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "80ceb966",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Data Setup\n",
+ "np.random.seed(42)\n",
+ "X = pd.DataFrame(\n",
+ " {\n",
+ " \"age\": np.random.randint(18, 80, 1000),\n",
+ " \"income\": np.random.randint(20000, 150000, 1000),\n",
+ " \"debt_ratio\": np.random.uniform(0.1, 0.8, 1000),\n",
+ " }\n",
+ ")\n",
+ "y = (\n",
+ " (\n",
+ " (X[\"age\"] < 30).astype(int) * 0.3\n",
+ " + (X[\"income\"] < 40000).astype(int) * 0.4\n",
+ " + (X[\"debt_ratio\"] > 0.6).astype(int) * 0.3\n",
+ " + np.random.random(1000) * 0.2\n",
+ " )\n",
+ " .round()\n",
+ " .astype(int)\n",
+ ")\n",
+ "\n",
+ "X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d21b31e7",
+ "metadata": {},
+ "source": [
+ "## Example\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "7cfc5dd3",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Model & Scorecard\n",
+ "model = xgb.XGBClassifier(max_depth=3, n_estimators=50, learning_rate=0.1, random_state=42)\n",
+ "model.fit(X_train, y_train)\n",
+ "\n",
+ "constructor = XGBScorecardConstructor(model, X_train, y_train)\n",
+ "scorecard = constructor.construct_scorecard()\n",
+ "\n",
+ "# Feature SHAP: per-feature decomposition (from TreeSHAP)\n",
+ "shap_full = extract_shap_values_xgb(model, X_test.head(10), constructor.base_score, False)\n",
+ "feature_shap_sum = shap_full[:, :-1].sum(axis=1) # Sum across features\n",
+ "shap_base_value = shap_full[0, -1]\n",
+ "\n",
+ "# XAddEvidence: per-tree decomposition (from scorecard)\n",
+ "leaf_indices = constructor.get_leafs(X_test, output_type=\"leaf_index\")\n",
+ "n_trees = len(scorecard[\"Tree\"].unique())\n",
+ "\n",
+ "# Base value adjustment (constructor.base_score vs SHAP base_value)\n",
+ "base_adjustment = constructor.base_score - shap_base_value"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "b0bd972c",
+ "metadata": {},
+ "source": [
+ "## XAddEvidence (with base adjustment)\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "d9a7f1e5",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Sum XAddEvidence from table across all trees (with base adjustment)\n",
+ "xaddevidence_sum = []\n",
+ "for idx in X_test.index[:10]:\n",
+ " obs_leafs = leaf_indices.loc[X_test.index.get_loc(idx)]\n",
+ " total = sum(\n",
+ " scorecard[(scorecard[\"Tree\"] == t) & (scorecard[\"Node\"] == obs_leafs.iloc[t])][\n",
+ " \"XAddEvidence\"\n",
+ " ].iloc[0]\n",
+ " for t in range(n_trees)\n",
+ " )\n",
+ " # Add base adjustment to make it equal to sum of feature SHAP\n",
+ " xaddevidence_sum.append(total + base_adjustment)\n",
+ "xaddevidence_sum = np.array(xaddevidence_sum)\n",
+ "\n",
+ "# PDO Scaling\n",
+ "pdo, target_points, target_odds = 50, 600, 19\n",
+ "factor = pdo / np.log(2)\n",
+ "offset = target_points - factor * np.log(target_odds)\n",
+ "intercept = factor * shap_base_value\n",
+ "\n",
+ "score_table = np.round(factor * (-xaddevidence_sum) - intercept + offset).astype(int)\n",
+ "score_feature = np.round(factor * (-feature_shap_sum) - intercept + offset).astype(int)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "a4b4f2d1",
+ "metadata": {},
+ "source": [
+ "## Results\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "fe317f4c",
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XAddEvidence (adjusted) vs Feature SHAP Comparison\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " XAddEvidence_adj \n",
+ " Feature_SHAP \n",
+ " Score_Table \n",
+ " Score_Feature \n",
+ " Diff \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 521 \n",
+ " -4.3082 \n",
+ " -4.3082 \n",
+ " 689 \n",
+ " 689 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 737 \n",
+ " -4.3680 \n",
+ " -4.3680 \n",
+ " 693 \n",
+ " 693 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 740 \n",
+ " 2.1726 \n",
+ " 2.1726 \n",
+ " 221 \n",
+ " 221 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 660 \n",
+ " -3.1005 \n",
+ " -3.1005 \n",
+ " 602 \n",
+ " 602 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 411 \n",
+ " -4.4824 \n",
+ " -4.4824 \n",
+ " 701 \n",
+ " 701 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 678 \n",
+ " -4.3082 \n",
+ " -4.3082 \n",
+ " 689 \n",
+ " 689 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 626 \n",
+ " -4.4824 \n",
+ " -4.4824 \n",
+ " 701 \n",
+ " 701 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 513 \n",
+ " 1.5022 \n",
+ " 1.5022 \n",
+ " 270 \n",
+ " 270 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 859 \n",
+ " -3.7160 \n",
+ " -3.7160 \n",
+ " 646 \n",
+ " 646 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 136 \n",
+ " 5.1997 \n",
+ " 5.1997 \n",
+ " 3 \n",
+ " 3 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " XAddEvidence_adj Feature_SHAP Score_Table Score_Feature Diff\n",
+ "521 -4.3082 -4.3082 689 689 0\n",
+ "737 -4.3680 -4.3680 693 693 0\n",
+ "740 2.1726 2.1726 221 221 0\n",
+ "660 -3.1005 -3.1005 602 602 0\n",
+ "411 -4.4824 -4.4824 701 701 0\n",
+ "678 -4.3082 -4.3082 689 689 0\n",
+ "626 -4.4824 -4.4824 701 701 0\n",
+ "513 1.5022 1.5022 270 270 0\n",
+ "859 -3.7160 -3.7160 646 646 0\n",
+ "136 5.1997 5.1997 3 3 0"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ },
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "Max difference: 0 points\n",
+ "Match exactly: True\n"
+ ]
+ }
+ ],
+ "source": [
+ "print(\"XAddEvidence (adjusted) vs Feature SHAP Comparison\")\n",
+ "results = pd.DataFrame(\n",
+ " {\n",
+ " \"XAddEvidence_adj\": xaddevidence_sum.round(4),\n",
+ " \"Feature_SHAP\": feature_shap_sum.round(4),\n",
+ " \"Score_Table\": score_table,\n",
+ " \"Score_Feature\": score_feature,\n",
+ " \"Diff\": score_table - score_feature,\n",
+ " },\n",
+ " index=X_test.index[:10],\n",
+ ")\n",
+ "display(results)\n",
+ "print(f\"\\nMax difference: {results['Diff'].abs().max()} points\")\n",
+ "print(f\"Match exactly: {(results['Diff'] == 0).all()}\")"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv (3.10.16)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.16"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/examples/shap-scorecard-examples.ipynb b/examples/shap-scorecard-examples.ipynb
new file mode 100644
index 0000000..ed39f9f
--- /dev/null
+++ b/examples/shap-scorecard-examples.ipynb
@@ -0,0 +1,1434 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# xBooster \n",
+ "\n",
+ "## SHAP Scorecards \n",
+ "\n",
+ "Repo: https://github.com/xRiskLab/xBooster \n",
+ "\n",
+ "This notebook shows how to use native SHAP values for scorecard construction with\n",
+ "XGBoost, LightGBM, and CatBoost.\n",
+ "\n",
+ "Examples of using native SHAP values for scorecards with XGBoost, LightGBM, and CatBoost.\n",
+ "\n",
+ "**Highlights**\n",
+ "\n",
+ "- SHAP computed only when calling `predict_score(..., method=\"shap\")`.\n",
+ "- Scorecards stay lightweight (no stored SHAP values).\n",
+ "- Native SHAP extraction—no external `shap` library.\n",
+ "- SHAP-based scoring and per-feature decomposition supported.\n",
+ "- Suitable for deeper models where interpretability is challenging.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import pandas as pd\n",
+ "import numpy as np\n",
+ "from sklearn.model_selection import train_test_split\n",
+ "from sklearn.metrics import roc_auc_score\n",
+ "\n",
+ "# Import xbooster constructors\n",
+ "from xbooster.xgb_constructor import XGBScorecardConstructor\n",
+ "from xbooster.lgb_constructor import LGBScorecardConstructor\n",
+ "from xbooster.cb_constructor import CBScorecardConstructor\n",
+ "\n",
+ "# Import model libraries\n",
+ "import xgboost as xgb\n",
+ "import lightgbm as lgb\n",
+ "from catboost import CatBoostClassifier, Pool"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Generate Sample Data\n",
+ "\n",
+ "We'll create a synthetic credit risk dataset for demonstration.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Training set: 800 samples\n",
+ "Test set: 200 samples\n",
+ "Default rate: 17.60%\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Generate synthetic credit risk data\n",
+ "np.random.seed(42)\n",
+ "n_samples = 1000\n",
+ "\n",
+ "X = pd.DataFrame(\n",
+ " {\n",
+ " \"age\": np.random.randint(18, 80, n_samples),\n",
+ " \"income\": np.random.randint(20000, 150000, n_samples),\n",
+ " \"credit_history\": np.random.randint(0, 10, n_samples),\n",
+ " \"debt_ratio\": np.random.uniform(0.1, 0.8, n_samples),\n",
+ " \"employment_years\": np.random.randint(0, 30, n_samples),\n",
+ " }\n",
+ ")\n",
+ "\n",
+ "# Create target with some relationship to features\n",
+ "y = (\n",
+ " (\n",
+ " (X[\"age\"] < 30).astype(int) * 0.3\n",
+ " + (X[\"income\"] < 40000).astype(int) * 0.4\n",
+ " + (X[\"debt_ratio\"] > 0.6).astype(int) * 0.3\n",
+ " + np.random.random(n_samples) * 0.2\n",
+ " )\n",
+ " .round()\n",
+ " .astype(int)\n",
+ ")\n",
+ "\n",
+ "# Split data\n",
+ "X_train, X_test, y_train, y_test = train_test_split(\n",
+ " X, y, test_size=0.2, random_state=42, stratify=y\n",
+ ")\n",
+ "\n",
+ "print(f\"Training set: {X_train.shape[0]} samples\")\n",
+ "print(f\"Test set: {X_test.shape[0]} samples\")\n",
+ "print(f\"Default rate: {y.mean():.2%}\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example 1: XGBoost with SHAP\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "XGBoost Gini: 0.9796\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Train XGBoost model with depth > 1\n",
+ "xgb_model = xgb.XGBClassifier(max_depth=3, n_estimators=50, learning_rate=0.1, random_state=42)\n",
+ "xgb_model.fit(X_train, y_train)\n",
+ "\n",
+ "# Evaluate model\n",
+ "xgb_pred = xgb_model.predict_proba(X_test)[:, 1]\n",
+ "gini_xgb = roc_auc_score(y_test, xgb_pred) * 2 - 1\n",
+ "print(f\"XGBoost Gini: {gini_xgb:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 6,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create scorecard constructor\n",
+ "xgb_constructor = XGBScorecardConstructor(xgb_model, X_train, y_train)\n",
+ "xgb_scorecard = xgb_constructor.construct_scorecard()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 11,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "Sample predictions (first 10):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 685 \n",
+ " 776.0 \n",
+ " 0.002929 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 666 \n",
+ " 758.0 \n",
+ " 0.003752 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 77 \n",
+ " 173.0 \n",
+ " 0.930465 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 26 \n",
+ " 123.0 \n",
+ " 0.964173 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 672 \n",
+ " 762.0 \n",
+ " 0.003498 \n",
+ " \n",
+ " \n",
+ " 5 \n",
+ " 690 \n",
+ " 782.0 \n",
+ " 0.002663 \n",
+ " \n",
+ " \n",
+ " 6 \n",
+ " 699 \n",
+ " 790.0 \n",
+ " 0.002376 \n",
+ " \n",
+ " \n",
+ " 7 \n",
+ " -7 \n",
+ " 82.0 \n",
+ " 0.976878 \n",
+ " \n",
+ " \n",
+ " 8 \n",
+ " 588 \n",
+ " 679.0 \n",
+ " 0.011058 \n",
+ " \n",
+ " \n",
+ " 9 \n",
+ " 676 \n",
+ " 767.0 \n",
+ " 0.003311 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "0 685 776.0 0.002929\n",
+ "1 666 758.0 0.003752\n",
+ "2 77 173.0 0.930465\n",
+ "3 26 123.0 0.964173\n",
+ "4 672 762.0 0.003498\n",
+ "5 690 782.0 0.002663\n",
+ "6 699 790.0 0.002376\n",
+ "7 -7 82.0 0.976878\n",
+ "8 588 679.0 0.011058\n",
+ "9 676 767.0 0.003311"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Predict scores using SHAP method (no binning table needed)\n",
+ "xgb_scores_shap = xgb_constructor.predict_score(X_test, method=\"shap\")\n",
+ "xgb_scores_leafs = xgb_constructor.predict_score(X_test) # Leaf-based scorecard (default)\n",
+ "\n",
+ "# Compare with actual model predictions\n",
+ "xgb_predictions = xgb_model.predict_proba(X_test)[:, 1]\n",
+ "\n",
+ "# Show sample predictions\n",
+ "xgb_comparison_df = pd.DataFrame(\n",
+ " {\n",
+ " \"SHAP_Score\": xgb_scores_shap.iloc[:10].values,\n",
+ " \"XAddEvidence_Score\": xgb_scores_leafs.iloc[:10].values,\n",
+ " \"Model_Prob\": xgb_predictions[:10],\n",
+ " }\n",
+ ")\n",
+ "print(\"\\nSample predictions (first 10):\")\n",
+ "display(xgb_comparison_df)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 12,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " 1.000000 \n",
+ " 0.999975 \n",
+ " -0.994904 \n",
+ " \n",
+ " \n",
+ " XAddEvidence_Score \n",
+ " 0.999975 \n",
+ " 1.000000 \n",
+ " -0.994602 \n",
+ " \n",
+ " \n",
+ " Model_Prob \n",
+ " -0.994904 \n",
+ " -0.994602 \n",
+ " 1.000000 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "SHAP_Score 1.000000 0.999975 -0.994904\n",
+ "XAddEvidence_Score 0.999975 1.000000 -0.994602\n",
+ "Model_Prob -0.994904 -0.994602 1.000000"
+ ]
+ },
+ "execution_count": 12,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "xgb_comparison_df.corr()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 13,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "=== XGBoost SHAP Score Decomposition ===\n",
+ "Feature-level decomposition shape: (200, 6)\n",
+ "Columns: ['age_score', 'income_score', 'credit_history_score', 'debt_ratio_score', 'employment_years_score', 'score']\n",
+ "\n",
+ "First 5 rows (showing feature contributions and total score):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " age_score \n",
+ " income_score \n",
+ " credit_history_score \n",
+ " debt_ratio_score \n",
+ " employment_years_score \n",
+ " score \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 122 \n",
+ " 274 \n",
+ " 78 \n",
+ " 142 \n",
+ " 69 \n",
+ " 685 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 127 \n",
+ " 285 \n",
+ " 78 \n",
+ " 111 \n",
+ " 65 \n",
+ " 666 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 114 \n",
+ " -161 \n",
+ " 77 \n",
+ " -37 \n",
+ " 84 \n",
+ " 77 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 120 \n",
+ " -188 \n",
+ " 70 \n",
+ " -54 \n",
+ " 78 \n",
+ " 26 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 126 \n",
+ " 280 \n",
+ " 74 \n",
+ " 116 \n",
+ " 76 \n",
+ " 672 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " age_score income_score ... employment_years_score score\n",
+ "0 122 274 ... 69 685\n",
+ "1 127 285 ... 65 666\n",
+ "2 114 -161 ... 84 77\n",
+ "3 120 -188 ... 78 26\n",
+ "4 126 280 ... 76 672\n",
+ "\n",
+ "[5 rows x 6 columns]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Decompose scores by feature using SHAP method\n",
+ "xgb_scores_decomposed = xgb_constructor.predict_scores(X_test, method=\"shap\")\n",
+ "print(\"=== XGBoost SHAP Score Decomposition ===\")\n",
+ "print(f\"Feature-level decomposition shape: {xgb_scores_decomposed.shape}\")\n",
+ "print(f\"Columns: {xgb_scores_decomposed.columns.tolist()}\")\n",
+ "print(\"\\nFirst 5 rows (showing feature contributions and total score):\")\n",
+ "display(xgb_scores_decomposed.head())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example 2: LightGBM with SHAP\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 14,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "LightGBM Gini: 0.9794\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Train LightGBM model with depth > 1\n",
+ "lgb_model = lgb.LGBMClassifier(\n",
+ " max_depth=3, n_estimators=50, learning_rate=0.1, random_state=42, verbose=-1\n",
+ ")\n",
+ "lgb_model.fit(X_train, y_train)\n",
+ "\n",
+ "# Evaluate model\n",
+ "lgb_pred = lgb_model.predict_proba(X_test)[:, 1]\n",
+ "gini_lgb = roc_auc_score(y_test, lgb_pred) * 2 - 1\n",
+ "print(f\"LightGBM Gini: {gini_lgb:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 15,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "# Create scorecard constructor\n",
+ "lgb_constructor = LGBScorecardConstructor(lgb_model, X_train, y_train)\n",
+ "lgb_scorecard = lgb_constructor.construct_scorecard()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 17,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "Model predictions - Mean: 0.1635\n",
+ "\n",
+ "Sample predictions (first 10):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 812 \n",
+ " 778.0 \n",
+ " 0.002745 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 814 \n",
+ " 779.0 \n",
+ " 0.002707 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 191 \n",
+ " 152.0 \n",
+ " 0.938611 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 133 \n",
+ " 96.0 \n",
+ " 0.971278 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 814 \n",
+ " 779.0 \n",
+ " 0.002712 \n",
+ " \n",
+ " \n",
+ " 5 \n",
+ " 818 \n",
+ " 783.0 \n",
+ " 0.002549 \n",
+ " \n",
+ " \n",
+ " 6 \n",
+ " 822 \n",
+ " 787.0 \n",
+ " 0.002433 \n",
+ " \n",
+ " \n",
+ " 7 \n",
+ " 41 \n",
+ " 1.0 \n",
+ " 0.991951 \n",
+ " \n",
+ " \n",
+ " 8 \n",
+ " 839 \n",
+ " 805.0 \n",
+ " 0.001935 \n",
+ " \n",
+ " \n",
+ " 9 \n",
+ " 818 \n",
+ " 783.0 \n",
+ " 0.002549 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "0 812 778.0 0.002745\n",
+ "1 814 779.0 0.002707\n",
+ "2 191 152.0 0.938611\n",
+ "3 133 96.0 0.971278\n",
+ "4 814 779.0 0.002712\n",
+ "5 818 783.0 0.002549\n",
+ "6 822 787.0 0.002433\n",
+ "7 41 1.0 0.991951\n",
+ "8 839 805.0 0.001935\n",
+ "9 818 783.0 0.002549"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Predict scores using SHAP method (no binning table needed)\n",
+ "lgb_scores_shap = lgb_constructor.predict_score(X_test, method=\"shap\")\n",
+ "lgb_scores_leafs = lgb_constructor.predict_score(X_test) # Leaf-based scorecard (default)\n",
+ "\n",
+ "# Compare with actual model predictions\n",
+ "lgb_predictions = lgb_model.predict_proba(X_test)[:, 1]\n",
+ "print(f\"\\nModel predictions - Mean: {lgb_predictions.mean():.4f}\")\n",
+ "\n",
+ "# Show sample predictions\n",
+ "lgb_comparison_df = pd.DataFrame(\n",
+ " {\n",
+ " \"SHAP_Score\": lgb_scores_shap.iloc[:10].values,\n",
+ " \"XAddEvidence_Score\": lgb_scores_leafs.iloc[:10].values,\n",
+ " \"Model_Prob\": lgb_predictions[:10],\n",
+ " }\n",
+ ")\n",
+ "print(\"\\nSample predictions (first 10):\")\n",
+ "display(lgb_comparison_df)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 18,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " 1.000000 \n",
+ " 0.999998 \n",
+ " -0.996616 \n",
+ " \n",
+ " \n",
+ " XAddEvidence_Score \n",
+ " 0.999998 \n",
+ " 1.000000 \n",
+ " -0.996564 \n",
+ " \n",
+ " \n",
+ " Model_Prob \n",
+ " -0.996616 \n",
+ " -0.996564 \n",
+ " 1.000000 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "SHAP_Score 1.000000 0.999998 -0.996616\n",
+ "XAddEvidence_Score 0.999998 1.000000 -0.996564\n",
+ "Model_Prob -0.996616 -0.996564 1.000000"
+ ]
+ },
+ "execution_count": 18,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "lgb_comparison_df.corr()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 19,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "=== LightGBM SHAP Score Decomposition ===\n",
+ "Feature-level decomposition shape: (200, 6)\n",
+ "Columns: ['age_score', 'income_score', 'credit_history_score', 'debt_ratio_score', 'employment_years_score', 'score']\n",
+ "\n",
+ "First 5 rows (showing feature contributions and total score):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " age_score \n",
+ " income_score \n",
+ " credit_history_score \n",
+ " debt_ratio_score \n",
+ " employment_years_score \n",
+ " score \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 151 \n",
+ " 220 \n",
+ " 135 \n",
+ " 171 \n",
+ " 135 \n",
+ " 812 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 157 \n",
+ " 222 \n",
+ " 141 \n",
+ " 159 \n",
+ " 135 \n",
+ " 814 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 174 \n",
+ " -301 \n",
+ " 146 \n",
+ " 32 \n",
+ " 140 \n",
+ " 191 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 179 \n",
+ " -337 \n",
+ " 132 \n",
+ " 17 \n",
+ " 142 \n",
+ " 133 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 156 \n",
+ " 220 \n",
+ " 136 \n",
+ " 163 \n",
+ " 139 \n",
+ " 814 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " age_score income_score ... employment_years_score score\n",
+ "0 151 220 ... 135 812\n",
+ "1 157 222 ... 135 814\n",
+ "2 174 -301 ... 140 191\n",
+ "3 179 -337 ... 142 133\n",
+ "4 156 220 ... 139 814\n",
+ "\n",
+ "[5 rows x 6 columns]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Decompose scores by feature using SHAP method\n",
+ "lgb_scores_decomposed = lgb_constructor.predict_scores(X_test, method=\"shap\")\n",
+ "print(\"=== LightGBM SHAP Score Decomposition ===\")\n",
+ "print(f\"Feature-level decomposition shape: {lgb_scores_decomposed.shape}\")\n",
+ "print(f\"Columns: {lgb_scores_decomposed.columns.tolist()}\")\n",
+ "print(\"\\nFirst 5 rows (showing feature contributions and total score):\")\n",
+ "display(lgb_scores_decomposed.head())"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Example 3: CatBoost with SHAP\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 20,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "CatBoost Gini: 0.9893\n"
+ ]
+ }
+ ],
+ "source": [
+ "# Train CatBoost model with depth > 1\n",
+ "cb_model = CatBoostClassifier(\n",
+ " max_depth=3, n_estimators=50, learning_rate=0.1, random_state=42, verbose=False\n",
+ ")\n",
+ "\n",
+ "# Create Pool for CatBoost\n",
+ "train_pool = Pool(X_train, y_train)\n",
+ "test_pool = Pool(X_test, y_test)\n",
+ "\n",
+ "cb_model.fit(train_pool)\n",
+ "\n",
+ "# Evaluate model\n",
+ "cb_pred = cb_model.predict_proba(test_pool)[:, 1]\n",
+ "cb_gini = roc_auc_score(y_test, cb_pred) * 2 - 1\n",
+ "print(f\"CatBoost Gini: {cb_gini:.4f}\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 21,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "Scorecard columns: ['Tree', 'LeafIndex', 'Feature', 'Sign', 'Split', 'CountPct', 'Count', 'NonEvents', 'Events', 'EventRate', 'XAddEvidence', 'WOE', 'IV', 'DetailedSplit']\n",
+ "\n",
+ "Scorecard shape: (400, 14)\n",
+ "\n",
+ "Note: SHAP values are NOT stored in the scorecard. They are computed on-demand when using predict_score(method='shap')\n",
+ "\n",
+ "First few rows of scorecard:\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " Tree \n",
+ " LeafIndex \n",
+ " Feature \n",
+ " XAddEvidence \n",
+ " Count \n",
+ " EventRate \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 0 \n",
+ " 0 \n",
+ " income \n",
+ " 0.097 \n",
+ " 62.0 \n",
+ " 0.790323 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 0 \n",
+ " 1 \n",
+ " income \n",
+ " 0.000 \n",
+ " 0.0 \n",
+ " 0.176250 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 0 \n",
+ " 2 \n",
+ " income \n",
+ " -0.076 \n",
+ " 17.0 \n",
+ " 0.176471 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 0 \n",
+ " 3 \n",
+ " income \n",
+ " -0.141 \n",
+ " 306.0 \n",
+ " 0.133987 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 0 \n",
+ " 4 \n",
+ " income \n",
+ " 0.047 \n",
+ " 69.0 \n",
+ " 0.637681 \n",
+ " \n",
+ " \n",
+ " 5 \n",
+ " 0 \n",
+ " 5 \n",
+ " income \n",
+ " 0.000 \n",
+ " 0.0 \n",
+ " 0.176250 \n",
+ " \n",
+ " \n",
+ " 6 \n",
+ " 0 \n",
+ " 6 \n",
+ " income \n",
+ " -0.086 \n",
+ " 23.0 \n",
+ " 0.173913 \n",
+ " \n",
+ " \n",
+ " 7 \n",
+ " 0 \n",
+ " 7 \n",
+ " income \n",
+ " -0.193 \n",
+ " 323.0 \n",
+ " 0.000000 \n",
+ " \n",
+ " \n",
+ " 8 \n",
+ " 1 \n",
+ " 0 \n",
+ " income \n",
+ " 0.087 \n",
+ " 10.0 \n",
+ " 1.000000 \n",
+ " \n",
+ " \n",
+ " 9 \n",
+ " 1 \n",
+ " 1 \n",
+ " income \n",
+ " -0.131 \n",
+ " 28.0 \n",
+ " 0.000000 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " Tree LeafIndex Feature XAddEvidence Count EventRate\n",
+ "0 0 0 income 0.097 62.0 0.790323\n",
+ "1 0 1 income 0.000 0.0 0.176250\n",
+ "2 0 2 income -0.076 17.0 0.176471\n",
+ "3 0 3 income -0.141 306.0 0.133987\n",
+ "4 0 4 income 0.047 69.0 0.637681\n",
+ "5 0 5 income 0.000 0.0 0.176250\n",
+ "6 0 6 income -0.086 23.0 0.173913\n",
+ "7 0 7 income -0.193 323.0 0.000000\n",
+ "8 1 0 income 0.087 10.0 1.000000\n",
+ "9 1 1 income -0.131 28.0 0.000000"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Create scorecard constructor\n",
+ "cb_constructor = CBScorecardConstructor(cb_model, train_pool)\n",
+ "\n",
+ "# Construct scorecard (SHAP is NOT stored in scorecard - computed on-demand only)\n",
+ "cb_scorecard = cb_constructor.construct_scorecard()\n",
+ "\n",
+ "print(\"Scorecard columns:\", cb_scorecard.columns.tolist())\n",
+ "print(f\"\\nScorecard shape: {cb_scorecard.shape}\")\n",
+ "print(\n",
+ " \"\\nNote: SHAP values are NOT stored in the scorecard. They are computed on-demand when using predict_score(method='shap')\"\n",
+ ")\n",
+ "print(\"\\nFirst few rows of scorecard:\")\n",
+ "display(\n",
+ " cb_scorecard[[\"Tree\", \"LeafIndex\", \"Feature\", \"XAddEvidence\", \"Count\", \"EventRate\"]].head(10)\n",
+ ")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 22,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "\n",
+ "Sample predictions (first 10):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 699 \n",
+ " 872.0 \n",
+ " 0.013236 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 699 \n",
+ " 888.0 \n",
+ " 0.012920 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 215 \n",
+ " 329.0 \n",
+ " 0.916289 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 230 \n",
+ " 354.0 \n",
+ " 0.899126 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 670 \n",
+ " 829.0 \n",
+ " 0.019590 \n",
+ " \n",
+ " \n",
+ " 5 \n",
+ " 706 \n",
+ " 885.0 \n",
+ " 0.011901 \n",
+ " \n",
+ " \n",
+ " 6 \n",
+ " 704 \n",
+ " 884.0 \n",
+ " 0.012361 \n",
+ " \n",
+ " \n",
+ " 7 \n",
+ " 318 \n",
+ " 428.0 \n",
+ " 0.728069 \n",
+ " \n",
+ " \n",
+ " 8 \n",
+ " 646 \n",
+ " 812.0 \n",
+ " 0.026979 \n",
+ " \n",
+ " \n",
+ " 9 \n",
+ " 703 \n",
+ " 884.0 \n",
+ " 0.012319 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "0 699 872.0 0.013236\n",
+ "1 699 888.0 0.012920\n",
+ "2 215 329.0 0.916289\n",
+ "3 230 354.0 0.899126\n",
+ "4 670 829.0 0.019590\n",
+ "5 706 885.0 0.011901\n",
+ "6 704 884.0 0.012361\n",
+ "7 318 428.0 0.728069\n",
+ "8 646 812.0 0.026979\n",
+ "9 703 884.0 0.012319"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Predict scores using SHAP method (no binning table needed)\n",
+ "cb_scores_shap = cb_constructor.predict_score(X_test, method=\"shap\")\n",
+ "cb_scores_leafs = cb_constructor.predict_score(\n",
+ " X_test, method=\"pdo\"\n",
+ ") # Leaf-based scorecard (default)\n",
+ "# Compare with actual model predictions\n",
+ "cb_predictions = cb_model.predict_proba(test_pool)[:, 1]\n",
+ "\n",
+ "# Show sample predictions\n",
+ "cb_comparison_df = pd.DataFrame(\n",
+ " {\n",
+ " \"SHAP_Score\": cb_scores_shap.iloc[:10].values,\n",
+ " \"XAddEvidence_Score\": cb_scores_leafs.iloc[:10].values,\n",
+ " \"Model_Prob\": cb_predictions[:10],\n",
+ " }\n",
+ ")\n",
+ "print(\"\\nSample predictions (first 10):\")\n",
+ "display(cb_comparison_df)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 23,
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " XAddEvidence_Score \n",
+ " Model_Prob \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " SHAP_Score \n",
+ " 1.000000 \n",
+ " 0.999293 \n",
+ " -0.997071 \n",
+ " \n",
+ " \n",
+ " XAddEvidence_Score \n",
+ " 0.999293 \n",
+ " 1.000000 \n",
+ " -0.995186 \n",
+ " \n",
+ " \n",
+ " Model_Prob \n",
+ " -0.997071 \n",
+ " -0.995186 \n",
+ " 1.000000 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " SHAP_Score XAddEvidence_Score Model_Prob\n",
+ "SHAP_Score 1.000000 0.999293 -0.997071\n",
+ "XAddEvidence_Score 0.999293 1.000000 -0.995186\n",
+ "Model_Prob -0.997071 -0.995186 1.000000"
+ ]
+ },
+ "execution_count": 23,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "cb_comparison_df.corr()"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 24,
+ "metadata": {},
+ "outputs": [
+ {
+ "name": "stdout",
+ "output_type": "stream",
+ "text": [
+ "=== CatBoost SHAP Score Decomposition ===\n",
+ "Feature-level decomposition shape: (200, 6)\n",
+ "Columns: ['age_score', 'income_score', 'credit_history_score', 'debt_ratio_score', 'employment_years_score', 'score']\n",
+ "\n",
+ "First 5 rows (showing feature contributions and total score):\n"
+ ]
+ },
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " age_score \n",
+ " income_score \n",
+ " credit_history_score \n",
+ " debt_ratio_score \n",
+ " employment_years_score \n",
+ " score \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 136 \n",
+ " 178 \n",
+ " 118 \n",
+ " 148 \n",
+ " 119 \n",
+ " 699 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 144 \n",
+ " 176 \n",
+ " 114 \n",
+ " 148 \n",
+ " 117 \n",
+ " 699 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 141 \n",
+ " -194 \n",
+ " 120 \n",
+ " 27 \n",
+ " 121 \n",
+ " 215 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 146 \n",
+ " -196 \n",
+ " 117 \n",
+ " 45 \n",
+ " 118 \n",
+ " 230 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 144 \n",
+ " 138 \n",
+ " 120 \n",
+ " 150 \n",
+ " 118 \n",
+ " 670 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
"
+ ],
+ "text/plain": [
+ " age_score income_score ... employment_years_score score\n",
+ "0 136 178 ... 119 699\n",
+ "1 144 176 ... 117 699\n",
+ "2 141 -194 ... 121 215\n",
+ "3 146 -196 ... 118 230\n",
+ "4 144 138 ... 118 670\n",
+ "\n",
+ "[5 rows x 6 columns]"
+ ]
+ },
+ "metadata": {},
+ "output_type": "display_data"
+ }
+ ],
+ "source": [
+ "# Decompose scores by feature using SHAP method\n",
+ "cb_scores_decomposed = cb_constructor.predict_scores(X_test, method=\"shap\")\n",
+ "print(\"=== CatBoost SHAP Score Decomposition ===\")\n",
+ "print(f\"Feature-level decomposition shape: {cb_scores_decomposed.shape}\")\n",
+ "print(f\"Columns: {cb_scores_decomposed.columns.tolist()}\")\n",
+ "print(\"\\nFirst 5 rows (showing feature contributions and total score):\")\n",
+ "display(cb_scores_decomposed.head())"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv (3.10.16)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.16"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 2
+}
diff --git a/examples/xbooster-trees-to-indices.ipynb b/examples/xbooster-trees-to-indices.ipynb
new file mode 100644
index 0000000..77307f6
--- /dev/null
+++ b/examples/xbooster-trees-to-indices.ipynb
@@ -0,0 +1,788 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "id": "6bc3fb21",
+ "metadata": {},
+ "source": [
+ "# xBooster \n",
+ "\n",
+ "## Trees to Indices \n",
+ "\n",
+ "Repo: https://github.com/xRiskLab/xBooster \n",
+ "\n",
+ "In this notebook, we show how to convert trees to indices.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 1,
+ "id": "066a3b31",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "from pathlib import Path\n",
+ "import pandas as pd\n",
+ "from sklearn.model_selection import train_test_split\n",
+ "from sklearn.compose import make_column_selector as selector\n",
+ "from sklearn.compose import ColumnTransformer\n",
+ "from sklearn.preprocessing import OneHotEncoder\n",
+ "\n",
+ "from sklearn import set_config\n",
+ "\n",
+ "set_config(transform_output=\"pandas\")"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 2,
+ "id": "eea8b160",
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "ROOT_DIR = Path.cwd()\n",
+ "DATA_DIR = ROOT_DIR / \"data\"\n",
+ "\n",
+ "data = pd.read_csv(DATA_DIR / \"train_u6lujuX_CVtuZ9i.csv\")\n",
+ "\n",
+ "X = data.drop(columns=[\"Loan_ID\", \"Loan_Status\", \"Gender\"])\n",
+ "y = data[\"Loan_Status\"].map({\"Y\": 0, \"N\": 1})\n",
+ "\n",
+ "numerical_columns_selector = selector(dtype_exclude=object)\n",
+ "categorical_columns_selector = selector(dtype_include=object)\n",
+ "\n",
+ "numerical_columns = numerical_columns_selector(X)\n",
+ "categorical_columns = categorical_columns_selector(X)\n",
+ "categorical_preprocessor = OneHotEncoder(handle_unknown=\"ignore\", sparse_output=False)\n",
+ "\n",
+ "# Convert numerical columns to float and categorical columns to string\n",
+ "X[numerical_columns] = X[numerical_columns].astype(float)\n",
+ "X[categorical_columns] = X[categorical_columns].astype(str).fillna(\"NA\")\n",
+ "\n",
+ "preprocessor = ColumnTransformer(\n",
+ " [\n",
+ " (\"one-hot-encoder\", categorical_preprocessor, categorical_columns),\n",
+ " ],\n",
+ " remainder=\"passthrough\",\n",
+ " verbose_feature_names_out=False,\n",
+ ")\n",
+ "\n",
+ "X_ohe = preprocessor.fit_transform(X)\n",
+ "\n",
+ "ix_train, ix_test = train_test_split(data.index, stratify=y, test_size=0.2, random_state=42)\n",
+ "X_train, X_test = X.iloc[ix_train], X.iloc[ix_test]\n",
+ "X_train_ohe, X_test_ohe = X_ohe.iloc[ix_train], X_ohe.iloc[ix_test]\n",
+ "y_train, y_test = y.iloc[ix_train], y.iloc[ix_test]"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "d4473105",
+ "metadata": {},
+ "source": [
+ "## XGBoost\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 3,
+ "id": "44bef358",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " tree_0 \n",
+ " tree_1 \n",
+ " tree_2 \n",
+ " tree_3 \n",
+ " tree_4 \n",
+ " tree_5 \n",
+ " tree_6 \n",
+ " tree_7 \n",
+ " tree_8 \n",
+ " tree_9 \n",
+ " ... \n",
+ " tree_90 \n",
+ " tree_91 \n",
+ " tree_92 \n",
+ " tree_93 \n",
+ " tree_94 \n",
+ " tree_95 \n",
+ " tree_96 \n",
+ " tree_97 \n",
+ " tree_98 \n",
+ " tree_99 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 5 \n",
+ " 5 \n",
+ " 7 \n",
+ " 7 \n",
+ " 9 \n",
+ " 7 \n",
+ " 11 \n",
+ " 9 \n",
+ " 11 \n",
+ " 9 \n",
+ " ... \n",
+ " 7 \n",
+ " 7 \n",
+ " 7 \n",
+ " 13 \n",
+ " 7 \n",
+ " 6 \n",
+ " 7 \n",
+ " 9 \n",
+ " 5 \n",
+ " 7 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 8 \n",
+ " 6 \n",
+ " 9 \n",
+ " 9 \n",
+ " 10 \n",
+ " 10 \n",
+ " 12 \n",
+ " 12 \n",
+ " 12 \n",
+ " 12 \n",
+ " ... \n",
+ " 7 \n",
+ " 11 \n",
+ " 8 \n",
+ " 12 \n",
+ " 8 \n",
+ " 6 \n",
+ " 7 \n",
+ " 9 \n",
+ " 7 \n",
+ " 8 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 7 \n",
+ " 8 \n",
+ " 9 \n",
+ " 9 \n",
+ " 10 \n",
+ " 7 \n",
+ " 12 \n",
+ " 10 \n",
+ " 12 \n",
+ " 9 \n",
+ " ... \n",
+ " 8 \n",
+ " 7 \n",
+ " 8 \n",
+ " 14 \n",
+ " 7 \n",
+ " 6 \n",
+ " 7 \n",
+ " 9 \n",
+ " 7 \n",
+ " 8 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 8 \n",
+ " 6 \n",
+ " 10 \n",
+ " 10 \n",
+ " 10 \n",
+ " 10 \n",
+ " 12 \n",
+ " 12 \n",
+ " 12 \n",
+ " 12 \n",
+ " ... \n",
+ " 7 \n",
+ " 13 \n",
+ " 8 \n",
+ " 14 \n",
+ " 8 \n",
+ " 6 \n",
+ " 7 \n",
+ " 9 \n",
+ " 6 \n",
+ " 8 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 6 \n",
+ " 5 \n",
+ " 8 \n",
+ " 8 \n",
+ " 9 \n",
+ " 7 \n",
+ " 11 \n",
+ " 10 \n",
+ " 11 \n",
+ " 9 \n",
+ " ... \n",
+ " 5 \n",
+ " 7 \n",
+ " 7 \n",
+ " 12 \n",
+ " 7 \n",
+ " 6 \n",
+ " 7 \n",
+ " 9 \n",
+ " 5 \n",
+ " 8 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
5 rows × 100 columns
\n",
+ "
"
+ ],
+ "text/plain": [
+ " tree_0 tree_1 tree_2 tree_3 tree_4 tree_5 tree_6 tree_7 tree_8 \\\n",
+ "0 5 5 7 7 9 7 11 9 11 \n",
+ "1 8 6 9 9 10 10 12 12 12 \n",
+ "2 7 8 9 9 10 7 12 10 12 \n",
+ "3 8 6 10 10 10 10 12 12 12 \n",
+ "4 6 5 8 8 9 7 11 10 11 \n",
+ "\n",
+ " tree_9 ... tree_90 tree_91 tree_92 tree_93 tree_94 tree_95 tree_96 \\\n",
+ "0 9 ... 7 7 7 13 7 6 7 \n",
+ "1 12 ... 7 11 8 12 8 6 7 \n",
+ "2 9 ... 8 7 8 14 7 6 7 \n",
+ "3 12 ... 7 13 8 14 8 6 7 \n",
+ "4 9 ... 5 7 7 12 7 6 7 \n",
+ "\n",
+ " tree_97 tree_98 tree_99 \n",
+ "0 9 5 7 \n",
+ "1 9 7 8 \n",
+ "2 9 7 8 \n",
+ "3 9 6 8 \n",
+ "4 9 5 8 \n",
+ "\n",
+ "[5 rows x 100 columns]"
+ ]
+ },
+ "execution_count": 3,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import xgboost as xgb\n",
+ "from xbooster.xgb_constructor import XGBScorecardConstructor\n",
+ "\n",
+ "model = xgb.XGBClassifier(n_estimators=100, max_depth=3, learning_rate=0.1)\n",
+ "model.fit(X_train_ohe, y_train)\n",
+ "\n",
+ "constructor = XGBScorecardConstructor(model, X_train_ohe, y_train)\n",
+ "scorecard = constructor.construct_scorecard()\n",
+ "\n",
+ "constructor.get_leafs(X_test_ohe, output_type=\"leaf_index\").head(5)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "ae16e4c2",
+ "metadata": {},
+ "source": [
+ "## LightGBM\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 4,
+ "id": "35d159f0",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " tree_0 \n",
+ " tree_1 \n",
+ " tree_2 \n",
+ " tree_3 \n",
+ " tree_4 \n",
+ " tree_5 \n",
+ " tree_6 \n",
+ " tree_7 \n",
+ " tree_8 \n",
+ " tree_9 \n",
+ " ... \n",
+ " tree_90 \n",
+ " tree_91 \n",
+ " tree_92 \n",
+ " tree_93 \n",
+ " tree_94 \n",
+ " tree_95 \n",
+ " tree_96 \n",
+ " tree_97 \n",
+ " tree_98 \n",
+ " tree_99 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 1 \n",
+ " 1 \n",
+ " 1 \n",
+ " 2 \n",
+ " 1 \n",
+ " 2 \n",
+ " 1 \n",
+ " 2 \n",
+ " 3 \n",
+ " 1 \n",
+ " ... \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 0 \n",
+ " 2 \n",
+ " 1 \n",
+ " 0 \n",
+ " 2 \n",
+ " 2 \n",
+ " 3 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 4 \n",
+ " 3 \n",
+ " 2 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 4 \n",
+ " 4 \n",
+ " ... \n",
+ " 3 \n",
+ " 4 \n",
+ " 5 \n",
+ " 4 \n",
+ " 4 \n",
+ " 3 \n",
+ " 5 \n",
+ " 5 \n",
+ " 2 \n",
+ " 3 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 2 \n",
+ " 4 \n",
+ " 2 \n",
+ " 3 \n",
+ " 1 \n",
+ " 3 \n",
+ " 4 \n",
+ " 3 \n",
+ " 4 \n",
+ " 1 \n",
+ " ... \n",
+ " 3 \n",
+ " 4 \n",
+ " 5 \n",
+ " 0 \n",
+ " 2 \n",
+ " 3 \n",
+ " 5 \n",
+ " 5 \n",
+ " 2 \n",
+ " 3 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 4 \n",
+ " 3 \n",
+ " 4 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 3 \n",
+ " 4 \n",
+ " 4 \n",
+ " ... \n",
+ " 2 \n",
+ " 5 \n",
+ " 4 \n",
+ " 5 \n",
+ " 4 \n",
+ " 3 \n",
+ " 1 \n",
+ " 5 \n",
+ " 2 \n",
+ " 3 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 3 \n",
+ " 1 \n",
+ " 3 \n",
+ " 2 \n",
+ " 1 \n",
+ " 2 \n",
+ " 1 \n",
+ " 2 \n",
+ " 2 \n",
+ " 1 \n",
+ " ... \n",
+ " 0 \n",
+ " 0 \n",
+ " 6 \n",
+ " 0 \n",
+ " 2 \n",
+ " 1 \n",
+ " 3 \n",
+ " 6 \n",
+ " 2 \n",
+ " 3 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
5 rows × 100 columns
\n",
+ "
"
+ ],
+ "text/plain": [
+ " tree_0 tree_1 tree_2 tree_3 tree_4 tree_5 tree_6 tree_7 tree_8 \\\n",
+ "0 1 1 1 2 1 2 1 2 3 \n",
+ "1 4 3 2 3 3 3 3 3 4 \n",
+ "2 2 4 2 3 1 3 4 3 4 \n",
+ "3 4 3 4 3 3 3 3 3 4 \n",
+ "4 3 1 3 2 1 2 1 2 2 \n",
+ "\n",
+ " tree_9 ... tree_90 tree_91 tree_92 tree_93 tree_94 tree_95 tree_96 \\\n",
+ "0 1 ... 3 3 3 0 2 1 0 \n",
+ "1 4 ... 3 4 5 4 4 3 5 \n",
+ "2 1 ... 3 4 5 0 2 3 5 \n",
+ "3 4 ... 2 5 4 5 4 3 1 \n",
+ "4 1 ... 0 0 6 0 2 1 3 \n",
+ "\n",
+ " tree_97 tree_98 tree_99 \n",
+ "0 2 2 3 \n",
+ "1 5 2 3 \n",
+ "2 5 2 3 \n",
+ "3 5 2 3 \n",
+ "4 6 2 3 \n",
+ "\n",
+ "[5 rows x 100 columns]"
+ ]
+ },
+ "execution_count": 4,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import lightgbm as lgb\n",
+ "from xbooster.lgb_constructor import LGBScorecardConstructor\n",
+ "\n",
+ "model = lgb.LGBMClassifier(n_estimators=100, max_depth=3, learning_rate=0.1, verbose=-1)\n",
+ "model.fit(X_train_ohe, y_train)\n",
+ "\n",
+ "constructor = LGBScorecardConstructor(model, X_train_ohe, y_train)\n",
+ "scorecard = constructor.construct_scorecard()\n",
+ "\n",
+ "constructor.get_leafs(X_test_ohe, output_type=\"leaf_index\").head(5)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "id": "9a5f14d9",
+ "metadata": {},
+ "source": [
+ "## CatBoost\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": 5,
+ "id": "ac17e662",
+ "metadata": {},
+ "outputs": [
+ {
+ "data": {
+ "text/html": [
+ "\n",
+ "\n",
+ "
\n",
+ " \n",
+ " \n",
+ " \n",
+ " tree_0 \n",
+ " tree_1 \n",
+ " tree_2 \n",
+ " tree_3 \n",
+ " tree_4 \n",
+ " tree_5 \n",
+ " tree_6 \n",
+ " tree_7 \n",
+ " tree_8 \n",
+ " tree_9 \n",
+ " ... \n",
+ " tree_90 \n",
+ " tree_91 \n",
+ " tree_92 \n",
+ " tree_93 \n",
+ " tree_94 \n",
+ " tree_95 \n",
+ " tree_96 \n",
+ " tree_97 \n",
+ " tree_98 \n",
+ " tree_99 \n",
+ " \n",
+ " \n",
+ " \n",
+ " \n",
+ " 0 \n",
+ " 5 \n",
+ " 1 \n",
+ " 1 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 5 \n",
+ " 5 \n",
+ " 4 \n",
+ " 3 \n",
+ " ... \n",
+ " 5 \n",
+ " 4 \n",
+ " 3 \n",
+ " 3 \n",
+ " 7 \n",
+ " 6 \n",
+ " 6 \n",
+ " 3 \n",
+ " 1 \n",
+ " 4 \n",
+ " \n",
+ " \n",
+ " 1 \n",
+ " 5 \n",
+ " 1 \n",
+ " 5 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 5 \n",
+ " 5 \n",
+ " 7 \n",
+ " 7 \n",
+ " ... \n",
+ " 5 \n",
+ " 4 \n",
+ " 7 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 6 \n",
+ " 3 \n",
+ " 1 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 2 \n",
+ " 5 \n",
+ " 1 \n",
+ " 5 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 5 \n",
+ " 5 \n",
+ " 7 \n",
+ " 7 \n",
+ " ... \n",
+ " 5 \n",
+ " 4 \n",
+ " 7 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 6 \n",
+ " 3 \n",
+ " 1 \n",
+ " 4 \n",
+ " \n",
+ " \n",
+ " 3 \n",
+ " 0 \n",
+ " 0 \n",
+ " 4 \n",
+ " 4 \n",
+ " 4 \n",
+ " 2 \n",
+ " 6 \n",
+ " 0 \n",
+ " 1 \n",
+ " 0 \n",
+ " ... \n",
+ " 5 \n",
+ " 4 \n",
+ " 7 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 6 \n",
+ " 3 \n",
+ " 1 \n",
+ " 0 \n",
+ " \n",
+ " \n",
+ " 4 \n",
+ " 7 \n",
+ " 1 \n",
+ " 7 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 5 \n",
+ " 5 \n",
+ " 5 \n",
+ " 3 \n",
+ " ... \n",
+ " 5 \n",
+ " 4 \n",
+ " 3 \n",
+ " 3 \n",
+ " 7 \n",
+ " 7 \n",
+ " 6 \n",
+ " 3 \n",
+ " 1 \n",
+ " 4 \n",
+ " \n",
+ " \n",
+ "
\n",
+ "
5 rows × 100 columns
\n",
+ "
"
+ ],
+ "text/plain": [
+ " tree_0 tree_1 tree_2 tree_3 tree_4 tree_5 tree_6 tree_7 tree_8 \\\n",
+ "0 5 1 1 3 7 7 5 5 4 \n",
+ "1 5 1 5 3 7 7 5 5 7 \n",
+ "2 5 1 5 3 7 7 5 5 7 \n",
+ "3 0 0 4 4 4 2 6 0 1 \n",
+ "4 7 1 7 3 7 7 5 5 5 \n",
+ "\n",
+ " tree_9 ... tree_90 tree_91 tree_92 tree_93 tree_94 tree_95 tree_96 \\\n",
+ "0 3 ... 5 4 3 3 7 6 6 \n",
+ "1 7 ... 5 4 7 3 7 7 6 \n",
+ "2 7 ... 5 4 7 3 7 7 6 \n",
+ "3 0 ... 5 4 7 3 7 7 6 \n",
+ "4 3 ... 5 4 3 3 7 7 6 \n",
+ "\n",
+ " tree_97 tree_98 tree_99 \n",
+ "0 3 1 4 \n",
+ "1 3 1 0 \n",
+ "2 3 1 4 \n",
+ "3 3 1 0 \n",
+ "4 3 1 4 \n",
+ "\n",
+ "[5 rows x 100 columns]"
+ ]
+ },
+ "execution_count": 5,
+ "metadata": {},
+ "output_type": "execute_result"
+ }
+ ],
+ "source": [
+ "import catboost as cb\n",
+ "from xbooster.cb_constructor import CBScorecardConstructor\n",
+ "\n",
+ "model = cb.CatBoostClassifier(\n",
+ " n_estimators=100,\n",
+ " max_depth=3,\n",
+ " learning_rate=0.1,\n",
+ " allow_writing_files=False,\n",
+ " verbose=0,\n",
+ " cat_features=categorical_columns,\n",
+ ")\n",
+ "model.fit(X_train, y_train)\n",
+ "\n",
+ "constructor = CBScorecardConstructor(model, X_train, y_train)\n",
+ "scorecard = constructor.construct_scorecard()\n",
+ "\n",
+ "constructor.get_leafs(X_test, output_type=\"leaf_index\").head(5)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": ".venv (3.10.16)",
+ "language": "python",
+ "name": "python3"
+ },
+ "language_info": {
+ "codemirror_mode": {
+ "name": "ipython",
+ "version": 3
+ },
+ "file_extension": ".py",
+ "mimetype": "text/x-python",
+ "name": "python",
+ "nbconvert_exporter": "python",
+ "pygments_lexer": "ipython3",
+ "version": "3.10.16"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 5
+}
diff --git a/pyproject.toml b/pyproject.toml
index 8b8f43f..64535e0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -17,7 +17,6 @@ exclude = [
include = [
"/xbooster",
"/tests",
- "/examples",
"/README.md",
"/LICENSE.md",
]
@@ -76,7 +75,8 @@ dev = [
"nbconvert>=7.16.6",
"pandas-stubs>=2.2.3",
"prek>=0.2.0",
- "ty>=0.0.1a21",
+ "mypy>=1.10.0",
+ "sourcery>=1.41.1; sys_platform == 'darwin'",
]
[tool.uv]
@@ -125,57 +125,12 @@ exclude = [
"examples/**",
]
-[tool.ty.rules]
-# Common ML libraries have incomplete type stubs
-unresolved-import = "ignore"
-unresolved-reference = "ignore"
-
-# Include type stubs directory if created in the future
-[tool.ty.src]
-include = ["xbooster", "typings"]
-
-# Override rules for examples - more lenient
-[[tool.ty.overrides]]
-include = ["examples/**"]
-[tool.ty.overrides.rules]
-unresolved-import = "ignore"
-unsupported-operator = "ignore"
-unresolved-attribute = "ignore"
-no-matching-overload = "ignore"
-
-# Override rules for tests - allow test patterns
-[[tool.ty.overrides]]
-include = ["tests/**"]
-[tool.ty.overrides.rules]
-unresolved-import = "ignore"
-no-matching-overload = "ignore"
-
-# Specific overrides for xbooster modules with XGBoost/CatBoost/LightGBM
-[[tool.ty.overrides]]
-include = ["xbooster/xgb_constructor.py", "xbooster/cb_constructor.py", "xbooster/catboost_*.py", "xbooster/lgb_constructor.py"]
-[tool.ty.overrides.rules]
-unresolved-attribute = "ignore"
-possibly-missing-attribute = "ignore"
-possibly-missing-import = "ignore"
-invalid-return-type = "ignore"
-invalid-assignment = "ignore"
-no-matching-overload = "ignore"
-invalid-argument-type = "ignore"
-
-# Override for parser and explainer - dynamic ML library attributes
-[[tool.ty.overrides]]
-include = ["xbooster/_parser.py", "xbooster/explainer.py"]
-[tool.ty.overrides.rules]
-possibly-missing-attribute = "ignore"
-invalid-argument-type = "ignore"
-
-# Override for utils - pandas/matplotlib type complexity
-[[tool.ty.overrides]]
-include = ["xbooster/_utils.py"]
-[tool.ty.overrides.rules]
-unsupported-operator = "ignore"
-invalid-argument-type = "ignore"
-not-iterable = "ignore"
+[tool.mypy]
+mypy_path = ["xbooster", "typings"]
+ignore_missing_imports = true
+disallow_untyped_defs = false
+check_untyped_defs = true
+exclude = ["examples/"]
[[tool.uv.index]]
name = "testpypi"
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 6dcd19e..0000000
--- a/requirements.txt
+++ /dev/null
@@ -1,774 +0,0 @@
-# This file was autogenerated by uv via the following command:
-# uv export --frozen --output-file=requirements.txt
--e .
-appnope==0.1.4 ; sys_platform == 'darwin' \
- --hash=sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee \
- --hash=sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c
- # via ipykernel
-astroid==3.3.9 \
- --hash=sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550 \
- --hash=sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248
- # via pylint
-asttokens==3.0.0 \
- --hash=sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7 \
- --hash=sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2
- # via stack-data
-attrs==25.3.0 \
- --hash=sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3 \
- --hash=sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b
- # via
- # jsonschema
- # referencing
-beautifulsoup4==4.13.4 \
- --hash=sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b \
- --hash=sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195
- # via nbconvert
-bleach==6.2.0 \
- --hash=sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e \
- --hash=sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f
- # via nbconvert
-catboost==1.2.8 \
- --hash=sha256:063020755d21de4f5434663a9b1d7cc1507c5b9254e2e8cd9cce9cd3b9ba4bbe \
- --hash=sha256:4a1d1aca5caecd919ec476f72c7abd98a704c24fda35506d4d7d71f77f07cb29 \
- --hash=sha256:5ac7c03a619d8eb86d70ec5748c1c9f09d4085033e3a760bf2f7c2892513c8a8 \
- --hash=sha256:8409c8a2e547469070d73681aa615b5e0b0d78367203d201b2f2b25c33cdcbad \
- --hash=sha256:b661840dc65e6ab4e62484dbf1556fed7736bb9196c6b5a3abb003cea39f0f91
- # via xbooster
-cffi==1.17.1 ; implementation_name == 'pypy' \
- --hash=sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8 \
- --hash=sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15 \
- --hash=sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824 \
- --hash=sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17 \
- --hash=sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702 \
- --hash=sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6 \
- --hash=sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e \
- --hash=sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be \
- --hash=sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67 \
- --hash=sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3 \
- --hash=sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c \
- --hash=sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14 \
- --hash=sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382
- # via pyzmq
-cfgv==3.4.0 \
- --hash=sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9 \
- --hash=sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560
- # via pre-commit
-cloudpickle==3.1.1 \
- --hash=sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64 \
- --hash=sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e
- # via shap
-colorama==0.4.6 ; sys_platform == 'win32' \
- --hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
- --hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
- # via
- # ipython
- # pylint
- # pytest
- # tqdm
-comm==0.2.2 \
- --hash=sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e \
- --hash=sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3
- # via ipykernel
-contourpy==1.3.2 \
- --hash=sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16 \
- --hash=sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9 \
- --hash=sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d \
- --hash=sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631 \
- --hash=sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2 \
- --hash=sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54 \
- --hash=sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934 \
- --hash=sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a \
- --hash=sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f \
- --hash=sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989 \
- --hash=sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad \
- --hash=sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512 \
- --hash=sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0 \
- --hash=sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c
- # via matplotlib
-cycler==0.12.1 \
- --hash=sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30 \
- --hash=sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c
- # via matplotlib
-debugpy==1.8.14 \
- --hash=sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79 \
- --hash=sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20 \
- --hash=sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322 \
- --hash=sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339 \
- --hash=sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987 \
- --hash=sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84
- # via ipykernel
-decorator==5.2.1 \
- --hash=sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360 \
- --hash=sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a
- # via ipython
-defusedxml==0.7.1 \
- --hash=sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69 \
- --hash=sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61
- # via nbconvert
-dill==0.4.0 \
- --hash=sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0 \
- --hash=sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049
- # via pylint
-distlib==0.3.9 \
- --hash=sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87 \
- --hash=sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403
- # via virtualenv
-exceptiongroup==1.2.2 \
- --hash=sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b \
- --hash=sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc
- # via
- # ipython
- # pytest
-executing==2.2.0 \
- --hash=sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa \
- --hash=sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755
- # via stack-data
-faker==37.1.0 \
- --hash=sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06 \
- --hash=sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c
-fastjsonschema==2.21.1 \
- --hash=sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4 \
- --hash=sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667
- # via nbformat
-filelock==3.18.0 \
- --hash=sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2 \
- --hash=sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de
- # via virtualenv
-fonttools==4.57.0 \
- --hash=sha256:3122c604a675513c68bd24c6a8f9091f1c2376d18e8f5fe5a101746c81b3e98f \
- --hash=sha256:34687a5d21f1d688d7d8d416cb4c5b9c87fca8a1797ec0d74b9fdebfa55c09ab \
- --hash=sha256:69ab81b66ebaa8d430ba56c7a5f9abe0183afefd3a2d6e483060343398b13fb1 \
- --hash=sha256:727ece10e065be2f9dd239d15dd5d60a66e17eac11aea47d447f9f03fdbc42de \
- --hash=sha256:7a64edd3ff6a7f711a15bd70b4458611fb240176ec11ad8845ccbab4fe6745db \
- --hash=sha256:81aa97669cd726349eb7bd43ca540cf418b279ee3caba5e2e295fb4e8f841c02 \
- --hash=sha256:babe8d1eb059a53e560e7bf29f8e8f4accc8b6cfb9b5fd10e485bde77e71ef41 \
- --hash=sha256:cc066cb98b912f525ae901a24cd381a656f024f76203bc85f78fcc9e66ae5aec \
- --hash=sha256:d639397de852f2ccfb3134b152c741406752640a266d9c1365b0f23d7b88077f \
- --hash=sha256:f0e9618630edd1910ad4f07f60d77c184b2f572c8ee43305ea3265675cbbfe7e
- # via matplotlib
-graphviz==0.20.3 \
- --hash=sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d \
- --hash=sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5
- # via catboost
-identify==2.6.9 \
- --hash=sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150 \
- --hash=sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf
- # via pre-commit
-iniconfig==2.1.0 \
- --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \
- --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760
- # via pytest
-ipykernel==6.29.5 \
- --hash=sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5 \
- --hash=sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215
-ipython==8.35.0 \
- --hash=sha256:d200b7d93c3f5883fc36ab9ce28a18249c7706e51347681f80a0aef9895f2520 \
- --hash=sha256:e6b7470468ba6f1f0a7b116bb688a3ece2f13e2f94138e508201fad677a788ba
- # via ipykernel
-isort==5.13.2 \
- --hash=sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109 \
- --hash=sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6
- # via pylint
-jedi==0.19.2 \
- --hash=sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0 \
- --hash=sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9
- # via ipython
-jinja2==3.1.6 \
- --hash=sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d \
- --hash=sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67
- # via nbconvert
-joblib==1.4.2 \
- --hash=sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6 \
- --hash=sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e
- # via scikit-learn
-jsonschema==4.23.0 \
- --hash=sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4 \
- --hash=sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566
- # via nbformat
-jsonschema-specifications==2024.10.1 \
- --hash=sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272 \
- --hash=sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf
- # via jsonschema
-jupyter-client==8.6.3 \
- --hash=sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419 \
- --hash=sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f
- # via
- # ipykernel
- # nbclient
-jupyter-core==5.7.2 \
- --hash=sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409 \
- --hash=sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9
- # via
- # ipykernel
- # jupyter-client
- # nbclient
- # nbconvert
- # nbformat
-jupyterlab-pygments==0.3.0 \
- --hash=sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d \
- --hash=sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780
- # via nbconvert
-kiwisolver==1.4.8 \
- --hash=sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c \
- --hash=sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605 \
- --hash=sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e \
- --hash=sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8 \
- --hash=sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff \
- --hash=sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0 \
- --hash=sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d \
- --hash=sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b \
- --hash=sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c \
- --hash=sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db \
- --hash=sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751 \
- --hash=sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271 \
- --hash=sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e \
- --hash=sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b \
- --hash=sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b \
- --hash=sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d \
- --hash=sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d \
- --hash=sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3 \
- --hash=sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f \
- --hash=sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c \
- --hash=sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a \
- --hash=sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed
- # via matplotlib
-lightgbm==4.6.0 \
- --hash=sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad \
- --hash=sha256:37089ee95664b6550a7189d887dbf098e3eadab03537e411f52c63c121e3ba4b \
- --hash=sha256:4d68712bbd2b57a0b14390cbf9376c1d5ed773fa2e71e099cac588703b590336 \
- --hash=sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed \
- --hash=sha256:cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d \
- --hash=sha256:cb1c59720eb569389c0ba74d14f52351b573af489f230032a1c9f314f8bab7fe
- # via xbooster
-llvmlite==0.44.0 \
- --hash=sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4 \
- --hash=sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408 \
- --hash=sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2 \
- --hash=sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8 \
- --hash=sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614 \
- --hash=sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791
- # via numba
-markupsafe==3.0.2 \
- --hash=sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b \
- --hash=sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579 \
- --hash=sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb \
- --hash=sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a \
- --hash=sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8 \
- --hash=sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158 \
- --hash=sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171 \
- --hash=sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d \
- --hash=sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c \
- --hash=sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0 \
- --hash=sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50
- # via
- # jinja2
- # nbconvert
-matplotlib==3.10.1 \
- --hash=sha256:02582304e352f40520727984a5a18f37e8187861f954fea9be7ef06569cf85b4 \
- --hash=sha256:0721a3fd3d5756ed593220a8b86808a36c5031fce489adb5b31ee6dbb47dd5b2 \
- --hash=sha256:2589659ea30726284c6c91037216f64a506a9822f8e50592d48ac16a2f29e044 \
- --hash=sha256:648406f1899f9a818cef8c0231b44dcfc4ff36f167101c3fd1c9151f24220fdc \
- --hash=sha256:8e875b95ac59a7908978fe307ecdbdd9a26af7fa0f33f474a27fcf8c99f64a19 \
- --hash=sha256:a97ff127f295817bc34517255c9db6e71de8eddaab7f837b7d341dee9f2f587f \
- --hash=sha256:d0673b4b8f131890eb3a1ad058d6e065fb3c6e71f160089b65f8515373394698 \
- --hash=sha256:d3809916157ba871bcdd33d3493acd7fe3037db5daa917ca6e77975a94cef779 \
- --hash=sha256:e8d2d0e3881b129268585bf4765ad3ee73a4591d77b9a18c214ac7e3a79fb2ba \
- --hash=sha256:ff2ae14910be903f4a24afdbb6d7d3a6c44da210fc7d42790b87aeac92238a16
- # via
- # catboost
- # xbooster
-matplotlib-inline==0.1.7 \
- --hash=sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90 \
- --hash=sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca
- # via
- # ipykernel
- # ipython
-mccabe==0.7.0 \
- --hash=sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325 \
- --hash=sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e
- # via pylint
-mistune==3.1.3 \
- --hash=sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9 \
- --hash=sha256:a7035c21782b2becb6be62f8f25d3df81ccb4d6fa477a6525b15af06539f02a0
- # via nbconvert
-narwhals==1.35.0 \
- --hash=sha256:07477d18487fbc940243b69818a177ed7119b737910a8a254fb67688b48a7c96 \
- --hash=sha256:7562af132fa3f8aaaf34dc96d7ec95bdca29d1c795e8fcf14e01edf1d32122bc
- # via plotly
-nbclient==0.10.2 \
- --hash=sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d \
- --hash=sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193
- # via nbconvert
-nbconvert==7.16.6 \
- --hash=sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b \
- --hash=sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582
-nbformat==5.10.4 \
- --hash=sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a \
- --hash=sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b
- # via
- # nbclient
- # nbconvert
-nest-asyncio==1.6.0 \
- --hash=sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe \
- --hash=sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c
- # via ipykernel
-nodeenv==1.9.1 \
- --hash=sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f \
- --hash=sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9
- # via pre-commit
-numba==0.61.2 \
- --hash=sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d \
- --hash=sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9 \
- --hash=sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642 \
- --hash=sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2 \
- --hash=sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a \
- --hash=sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd
- # via shap
-numpy==1.26.4 \
- --hash=sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010 \
- --hash=sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a \
- --hash=sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a \
- --hash=sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0 \
- --hash=sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2 \
- --hash=sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5 \
- --hash=sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07 \
- --hash=sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4 \
- --hash=sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f
- # via
- # catboost
- # contourpy
- # lightgbm
- # matplotlib
- # numba
- # pandas
- # pandas-stubs
- # scikit-learn
- # scipy
- # shap
- # xbooster
- # xgboost
-nvidia-nccl-cu12==2.26.2.post1 ; platform_machine != 'aarch64' and sys_platform == 'linux' \
- --hash=sha256:5ffda04fd1296a90aae76a7e9999d9d3d69ffcec6573109a286fdd2158599114 \
- --hash=sha256:65640d57dd2a20de11048f0a163dd6b6ba8ae97eb3bfc11d80d3c409566bfce7
- # via xgboost
-packaging==24.2 \
- --hash=sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759 \
- --hash=sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f
- # via
- # ipykernel
- # matplotlib
- # nbconvert
- # plotly
- # pytest
- # shap
-pandas==2.2.3 \
- --hash=sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5 \
- --hash=sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f \
- --hash=sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348 \
- --hash=sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667 \
- --hash=sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645 \
- --hash=sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57 \
- --hash=sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42 \
- --hash=sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed
- # via
- # catboost
- # shap
- # xbooster
-pandas-stubs==2.2.3.250308 \
- --hash=sha256:3a6e9daf161f00b85c83772ed3d5cff9522028f07a94817472c07b91f46710fd \
- --hash=sha256:a377edff3b61f8b268c82499fdbe7c00fdeed13235b8b71d6a1dc347aeddc74d
-pandocfilters==1.5.1 \
- --hash=sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e \
- --hash=sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc
- # via nbconvert
-parso==0.8.4 \
- --hash=sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18 \
- --hash=sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d
- # via jedi
-pexpect==4.9.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' \
- --hash=sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523 \
- --hash=sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f
- # via ipython
-pillow==11.2.1 \
- --hash=sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97 \
- --hash=sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95 \
- --hash=sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2 \
- --hash=sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d \
- --hash=sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013 \
- --hash=sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c \
- --hash=sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0 \
- --hash=sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156 \
- --hash=sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad \
- --hash=sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6 \
- --hash=sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772 \
- --hash=sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61 \
- --hash=sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01 \
- --hash=sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1 \
- --hash=sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d \
- --hash=sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579 \
- --hash=sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047 \
- --hash=sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193 \
- --hash=sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363
- # via matplotlib
-platformdirs==4.3.7 \
- --hash=sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94 \
- --hash=sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351
- # via
- # jupyter-core
- # pylint
- # virtualenv
-plotly==6.0.1 \
- --hash=sha256:4714db20fea57a435692c548a4eb4fae454f7daddf15f8d8ba7e1045681d7768 \
- --hash=sha256:dd8400229872b6e3c964b099be699f8d00c489a974f2cfccfad5e8240873366b
- # via catboost
-pluggy==1.5.0 \
- --hash=sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1 \
- --hash=sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669
- # via pytest
-pre-commit==4.2.0 \
- --hash=sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146 \
- --hash=sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd
-prek==0.2.3 \
- --hash=sha256:030a41c816d2558326f3ad9adab35e412280fc3183a81a23f450b86b9525aa1d \
- --hash=sha256:1193882d0e1fb21b757ccc53a34435d229a2f38ba441edd2c66289532b23d96a \
- --hash=sha256:216c06989e421f79bf5a9eb3df1c470878438fac1bc0a636e03fc4d614bf219e \
- --hash=sha256:21cb38ae352772477474cb4c3cd9e9056a43ba7779e634bc23826b6dd01941f5 \
- --hash=sha256:2d24575deb40486a1f08799b1e5e17f3be685cbfe4b5071eb93911a9c2728841 \
- --hash=sha256:2de997b640350a4653c267e6e6e681a0902ab436a8a7e659ab1bced213249f79 \
- --hash=sha256:3431ebc218d7a7fc0f708c9eec4027949a081870f4f9fba3a7d65c706db87b72 \
- --hash=sha256:37467f2752ce0d4ca6451970d6863211db0ce3f390c74e91db790aad51357eb8 \
- --hash=sha256:4520c1e0827775af9112787cf3b005a3deee6baeb5d6ebd71004a20b9a7ea73b \
- --hash=sha256:466eb9ff44575c95b7442751ea86c2f0e9c8c188e7cb79a83134c3a768631c20 \
- --hash=sha256:4f6f90c5adda349110a9ff6ca50cda8200d9fb10a6891ca9c89f179cb789c957 \
- --hash=sha256:5c8bbdc6f4313d989327407a516b59e27624ff16c75f939c497c9cacf6e4fbba \
- --hash=sha256:7f567b7f2aab8b7dc09e23bab377df69f172b7ccc630bd98fadd103f04878a0e \
- --hash=sha256:a0df9d89618ea8060e766ec21f67bf6c0fac4f320fcbf3073919630b17494996 \
- --hash=sha256:b7a08dacd23791392e9807f4f42631aa1ac53b5907256e4c94af416833b23d00 \
- --hash=sha256:c8eab4a8d71b978f35a73b7a0e074bbe88c8982e2446a05bfc09fcc046b9a2c2 \
- --hash=sha256:c90e15f8617a956a9d2b0c612783eec585a355da685b8f44d338af62ba667c55 \
- --hash=sha256:e4afc3876dc812a55dfe7d48c0878bdfadc035697292dbfd6a6bd819d845721b \
- --hash=sha256:e5ba2b767f1ab011a41592dbc2b41ab9c4641e39b763ea0a0519e1d7b83f79ee
-prompt-toolkit==3.0.51 \
- --hash=sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07 \
- --hash=sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed
- # via ipython
-psutil==7.0.0 \
- --hash=sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25 \
- --hash=sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91 \
- --hash=sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da \
- --hash=sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34 \
- --hash=sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553 \
- --hash=sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456 \
- --hash=sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993 \
- --hash=sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99
- # via ipykernel
-ptyprocess==0.7.0 ; sys_platform != 'emscripten' and sys_platform != 'win32' \
- --hash=sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35 \
- --hash=sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220
- # via pexpect
-pure-eval==0.2.3 \
- --hash=sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0 \
- --hash=sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42
- # via stack-data
-pyarrow==19.0.1 \
- --hash=sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e \
- --hash=sha256:41f9706fbe505e0abc10e84bf3a906a1338905cbbcf1177b71486b03e6ea6608 \
- --hash=sha256:65cf9feebab489b19cdfcfe4aa82f62147218558d8d3f0fc1e9dea0ab8e7905a \
- --hash=sha256:ad76aef7f5f7e4a757fddcdcf010a8290958f09e3470ea458c80d26f4316ae89 \
- --hash=sha256:c6cb2335a411b713fdf1e82a752162f72d4a7b5dbc588e32aa18383318b05866 \
- --hash=sha256:d03c9d6f2a3dffbd62671ca070f13fc527bb1867b4ec2b98c7eeed381d4f389a \
- --hash=sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69 \
- --hash=sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec
- # via xbooster
-pycparser==2.22 ; implementation_name == 'pypy' \
- --hash=sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6 \
- --hash=sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc
- # via cffi
-pygments==2.19.1 \
- --hash=sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f \
- --hash=sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c
- # via
- # ipython
- # nbconvert
-pylint==3.3.6 \
- --hash=sha256:8b7c2d3e86ae3f94fb27703d521dd0b9b6b378775991f504d7c3a6275aa0a6a6 \
- --hash=sha256:b634a041aac33706d56a0d217e6587228c66427e20ec21a019bc4cdee48c040a
-pyparsing==3.2.3 \
- --hash=sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf \
- --hash=sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be
- # via matplotlib
-pytest==8.3.5 \
- --hash=sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820 \
- --hash=sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845
-python-dateutil==2.9.0.post0 \
- --hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
- --hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
- # via
- # jupyter-client
- # matplotlib
- # pandas
-pytz==2025.2 \
- --hash=sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3 \
- --hash=sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00
- # via pandas
-pywin32==310 ; platform_python_implementation != 'PyPy' and sys_platform == 'win32' \
- --hash=sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213 \
- --hash=sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1 \
- --hash=sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d
- # via jupyter-core
-pyyaml==6.0.2 \
- --hash=sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086 \
- --hash=sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68 \
- --hash=sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf \
- --hash=sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99 \
- --hash=sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b \
- --hash=sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237 \
- --hash=sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180 \
- --hash=sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e \
- --hash=sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e \
- --hash=sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed
- # via pre-commit
-pyzmq==26.4.0 \
- --hash=sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918 \
- --hash=sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4 \
- --hash=sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315 \
- --hash=sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d \
- --hash=sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb \
- --hash=sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5 \
- --hash=sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b \
- --hash=sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a \
- --hash=sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b \
- --hash=sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f \
- --hash=sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94 \
- --hash=sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba \
- --hash=sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5 \
- --hash=sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b \
- --hash=sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980 \
- --hash=sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a \
- --hash=sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b
- # via
- # ipykernel
- # jupyter-client
-referencing==0.36.2 \
- --hash=sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa \
- --hash=sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0
- # via
- # jsonschema
- # jsonschema-specifications
-rpds-py==0.24.0 \
- --hash=sha256:006f4342fe729a368c6df36578d7a348c7c716be1da0a1a0f86e3021f8e98724 \
- --hash=sha256:041f00419e1da7a03c46042453598479f45be3d787eb837af382bfc169c0db33 \
- --hash=sha256:1b221c2457d92a1fb3c97bee9095c874144d196f47c038462ae6e4a14436f7bc \
- --hash=sha256:208b3a70a98cf3710e97cabdc308a51cd4f28aa6e7bb11de3d56cd8b74bab98d \
- --hash=sha256:2d53747da70a4e4b17f559569d5f9506420966083a31c5fbd84e764461c4444b \
- --hash=sha256:43dba99f00f1d37b2a0265a259592d05fcc8e7c19d140fe51c6e6f16faabeb1f \
- --hash=sha256:4b28e5122829181de1898c2c97f81c0b3246d49f585f22743a1246420bb8d399 \
- --hash=sha256:60748789e028d2a46fc1c70750454f83c6bdd0d05db50f5ae83e2db500b34da5 \
- --hash=sha256:619ca56a5468f933d940e1bf431c6f4e13bef8e688698b067ae68eb4f9b30e3a \
- --hash=sha256:66420986c9afff67ef0c5d1e4cdc2d0e5262f53ad11e4f90e5e22448df485bf0 \
- --hash=sha256:6e1daf5bf6c2be39654beae83ee6b9a12347cb5aced9a29eecf12a2d25fff664 \
- --hash=sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e \
- --hash=sha256:7e80d375134ddb04231a53800503752093dbb65dad8dabacce2c84cccc78e964 \
- --hash=sha256:896c41007931217a343eff197c34513c154267636c8056fb409eafd494c3dcdc \
- --hash=sha256:92558d37d872e808944c3c96d0423b8604879a3d1c86fdad508d7ed91ea547d5 \
- --hash=sha256:a88c0d17d039333a41d9bf4616bd062f0bd7aa0edeb6cafe00a2fc2a804e944f \
- --hash=sha256:b9a4df06c35465ef4d81799999bba810c68d29972bf1c31db61bfdb81dd9d5bb \
- --hash=sha256:bbc4362e06f950c62cad3d4abf1191021b2ffaf0b31ac230fbf0526453eee75e \
- --hash=sha256:c0145295ca415668420ad142ee42189f78d27af806fcf1f32a18e51d47dd2052 \
- --hash=sha256:cc31e13ce212e14a539d430428cd365e74f8b2d534f8bc22dd4c9c55b277b875 \
- --hash=sha256:d3aa13bdf38630da298f2e0d77aca967b200b8cc1473ea05248f6c5e9c9bdb44 \
- --hash=sha256:d8754d872a5dfc3c5bf9c0e059e8107451364a30d9fd50f1f1a85c4fb9481164 \
- --hash=sha256:e8acd55bd5b071156bae57b555f5d33697998752673b9de554dd82f5b5352727 \
- --hash=sha256:e8e5ab32cf9eb3647450bc74eb201b27c185d3857276162c101c0f8c6374e098 \
- --hash=sha256:ebea2821cdb5f9fef44933617be76185b80150632736f3d76e54829ab4a3b4d1 \
- --hash=sha256:fc2c1e1b00f88317d9de6b2c2b39b012ebbfe35fe5e7bef980fd2a91f6100a07
- # via
- # jsonschema
- # referencing
-ruff==0.11.6 \
- --hash=sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2 \
- --hash=sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6 \
- --hash=sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc \
- --hash=sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e \
- --hash=sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79 \
- --hash=sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03 \
- --hash=sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2 \
- --hash=sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287 \
- --hash=sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193 \
- --hash=sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9 \
- --hash=sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de \
- --hash=sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308 \
- --hash=sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b \
- --hash=sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79 \
- --hash=sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e \
- --hash=sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1 \
- --hash=sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a \
- --hash=sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55
-scikit-learn==1.6.1 \
- --hash=sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36 \
- --hash=sha256:775da975a471c4f6f467725dff0ced5c7ac7bda5e9316b260225b48475279a1b \
- --hash=sha256:8634c4bd21a2a813e0a7e3900464e6d593162a29dd35d25bdf0103b3fce60ed5 \
- --hash=sha256:8a600c31592bd7dab31e1c61b9bbd6dea1b3433e67d264d17ce1017dbdce8002 \
- --hash=sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e \
- --hash=sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e
- # via
- # shap
- # xbooster
-scipy==1.15.2 \
- --hash=sha256:69ea6e56d00977f355c0f84eba69877b6df084516c602d93a33812aa04d90a3d \
- --hash=sha256:6f223753c6ea76983af380787611ae1291e3ceb23917393079dcc746ba60cfb5 \
- --hash=sha256:87994da02e73549dfecaed9e09a4f9d58a045a053865679aeb8d6d43747d4df3 \
- --hash=sha256:888307125ea0c4466287191e5606a2c910963405ce9671448ff9c81c53f85f58 \
- --hash=sha256:9412f5e408b397ff5641080ed1e798623dbe1ec0d78e72c9eca8992976fa65aa \
- --hash=sha256:9b18aa747da280664642997e65aab1dd19d0c3d17068a04b3fe34e2559196cb9 \
- --hash=sha256:a2ec871edaa863e8213ea5df811cd600734f6400b4af272e1c011e69401218e9 \
- --hash=sha256:b5e025e903b4f166ea03b109bb241355b9c42c279ea694d8864d033727205e65 \
- --hash=sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec \
- --hash=sha256:ecf797d2d798cf7c838c6d98321061eb3e72a74710e6c40540f0e8087e3b499e
- # via
- # catboost
- # lightgbm
- # scikit-learn
- # shap
- # xbooster
- # xgboost
-shap==0.47.2 \
- --hash=sha256:03d014351cb0ead1671ccb46cf38e7b3e810a19c3b6f79f4f84bb183b7c368bd \
- --hash=sha256:4dd04511eebcb3c4407e5e4f2818d9be792434989f32c9cb25b1f1fa3ca3309b \
- --hash=sha256:6579a3ba14fdae6280041d4306e75899ff900b6f302e77f0b379e4504b19a735 \
- --hash=sha256:8a53901fc44396d92a12b985d83ca4a47a7dcc4a04a7f7e556232a35f17d30c8 \
- --hash=sha256:a177f1c996ac64690cc9bceed00bdb5ec406dfadc7bf87e5a79dea28607a768c \
- --hash=sha256:adbae48a3e844d81f8ad41c2d0cbf90bc82708e00238cc6b024690b84a5d5dcb \
- --hash=sha256:f0179d33aaab65bf6089ab802575308da389c54392d9f3054c3d2d04d78ce3d0
- # via xbooster
-six==1.17.0 \
- --hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
- --hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
- # via
- # catboost
- # python-dateutil
-slicer==0.0.8 \
- --hash=sha256:2e7553af73f0c0c2d355f4afcc3ecf97c6f2156fcf4593955c3f56cf6c4d6eb7 \
- --hash=sha256:6c206258543aecd010d497dc2eca9d2805860a0b3758673903456b7df7934dc3
- # via shap
-soupsieve==2.6 \
- --hash=sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb \
- --hash=sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9
- # via beautifulsoup4
-stack-data==0.6.3 \
- --hash=sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9 \
- --hash=sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695
- # via ipython
-threadpoolctl==3.6.0 \
- --hash=sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb \
- --hash=sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e
- # via scikit-learn
-tinycss2==1.4.0 \
- --hash=sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7 \
- --hash=sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289
- # via bleach
-tomli==2.2.1 \
- --hash=sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc \
- --hash=sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff
- # via
- # pylint
- # pytest
-tomlkit==0.13.2 \
- --hash=sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde \
- --hash=sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79
- # via pylint
-tornado==6.4.2 \
- --hash=sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803 \
- --hash=sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec \
- --hash=sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482 \
- --hash=sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634 \
- --hash=sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38 \
- --hash=sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b \
- --hash=sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c \
- --hash=sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf \
- --hash=sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946 \
- --hash=sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73 \
- --hash=sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1
- # via
- # ipykernel
- # jupyter-client
-tqdm==4.67.1 \
- --hash=sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2 \
- --hash=sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2
- # via shap
-traitlets==5.14.3 \
- --hash=sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7 \
- --hash=sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f
- # via
- # comm
- # ipykernel
- # ipython
- # jupyter-client
- # jupyter-core
- # matplotlib-inline
- # nbclient
- # nbconvert
- # nbformat
-ty==0.0.1a25 \
- --hash=sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc \
- --hash=sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445 \
- --hash=sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979 \
- --hash=sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703 \
- --hash=sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9 \
- --hash=sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511 \
- --hash=sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35 \
- --hash=sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068 \
- --hash=sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf \
- --hash=sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7 \
- --hash=sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb \
- --hash=sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01 \
- --hash=sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d \
- --hash=sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447 \
- --hash=sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d \
- --hash=sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b \
- --hash=sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a \
- --hash=sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62
-types-pytz==2025.2.0.20250326 \
- --hash=sha256:3c397fd1b845cd2b3adc9398607764ced9e578a98a5d1fbb4a9bc9253edfb162 \
- --hash=sha256:deda02de24f527066fc8d6a19e284ab3f3ae716a42b4adb6b40e75e408c08d36
- # via pandas-stubs
-typing-extensions==4.13.2 \
- --hash=sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c \
- --hash=sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef
- # via
- # astroid
- # beautifulsoup4
- # ipython
- # mistune
- # referencing
- # shap
-tzdata==2025.2 \
- --hash=sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8 \
- --hash=sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9
- # via
- # faker
- # pandas
-virtualenv==20.30.0 \
- --hash=sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8 \
- --hash=sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6
- # via pre-commit
-wcwidth==0.2.13 \
- --hash=sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859 \
- --hash=sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5
- # via prompt-toolkit
-webencodings==0.5.1 \
- --hash=sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78 \
- --hash=sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923
- # via
- # bleach
- # tinycss2
-xgboost==3.0.5 \
- --hash=sha256:1a57a0d64a06b596992b664fe17dd1f9782138c6e35ad8fb6355e19a359fa50f \
- --hash=sha256:40c324c329bf74f44571cdafb7aa7435366309d76ec14b3e16874862aeb14351 \
- --hash=sha256:660774249d28a729ba8d22dd3d2c048c56e58f65a683b25ef3252e3383fe956f \
- --hash=sha256:a03210a3e54c9e543f480db9636fee57247cfcd1ae850b353aeac59eea5ca350 \
- --hash=sha256:c580c82500ad566d927581381550561300492c0bc9c143b2e5be208d16f093d1 \
- --hash=sha256:d0fe44aaca76e9c4598d3be98ae94661aa53e4c4ceb161dc7183258f0e6fc138 \
- --hash=sha256:d7f57a04629b52bae91a80e6721b9cdd009b605827a9eca67953675292b4487e \
- --hash=sha256:f974028c7a0c3ae51ef87e2900405436ddafff46452f6f334f5cc295e921429b
- # via xbooster
diff --git a/tests/test_cb_constructor.py b/tests/test_cb_constructor.py
index 38ae831..18d4d24 100644
--- a/tests/test_cb_constructor.py
+++ b/tests/test_cb_constructor.py
@@ -1,7 +1,7 @@
"""
Test module for xbooster.cb_constructor.
-The CatBoostScorecardConstructor class is responsible for constructing a scorecard from
+The CBScorecardConstructor class is responsible for constructing a scorecard from
a trained CatBoost model. This module provides test cases to ensure that the class
functions correctly.
@@ -21,15 +21,16 @@
from __future__ import annotations
+import os
+
import numpy as np
import pandas as pd
import pytest
from catboost import CatBoostClassifier, Pool
from sklearn.datasets import make_classification
from sklearn.metrics import roc_auc_score
-import os
-from xbooster.constructor import CatBoostScorecardConstructor
+from xbooster.constructor import CBScorecardConstructor
@pytest.fixture(scope="module")
@@ -97,14 +98,14 @@ def cb_model():
@pytest.fixture(scope="module")
def scorecard_constructor(cb_model):
"""
- Constructs a CatBoostScorecardConstructor object using the given CatBoost model,
+ Constructs a CBScorecardConstructor object using the given CatBoost model,
feature matrix (X), and target variable (y).
Parameters:
- cb_model: The trained CatBoost model.
Returns:
- - CatBoostScorecardConstructor: The initialized CatBoostScorecardConstructor object.
+ - CBScorecardConstructor: The initialized CBScorecardConstructor object.
"""
X = pd.DataFrame(
{
@@ -156,7 +157,7 @@ def scorecard_constructor(cb_model):
)
y = pd.Series([1, 0, 1, 1, 0, 0, 0, 1, 0, 1, 0, 0, 0, 1, 0, 0, 0, 1, 0, 0])
pool = Pool(X, y)
- return CatBoostScorecardConstructor(cb_model, pool)
+ return CBScorecardConstructor(cb_model, pool)
@pytest.fixture(scope="module")
@@ -304,7 +305,7 @@ def test_construct_scorecard(scorecard_constructor):
def test_create_points(scorecard_constructor):
"""
- Test the create_points method of the CatBoostScorecardConstructor class.
+ Test the create_points method of the CBScorecardConstructor class.
This test verifies that the create_points method returns a non-empty
pandas DataFrame with the expected columns.
@@ -321,7 +322,7 @@ def test_create_points(scorecard_constructor):
def test_predict_score(scorecard_constructor, X_test):
"""
- Test the predict_score method of the CatBoostScorecardConstructor class.
+ Test the predict_score method of the CBScorecardConstructor class.
This test verifies that the predict_score method returns a non-empty
pandas Series with predicted scores.
@@ -339,7 +340,7 @@ def test_predict_score(scorecard_constructor, X_test):
def test_predict_scores(scorecard_constructor, X_test):
"""
- Test the predict_scores method of the CatBoostScorecardConstructor class.
+ Test the predict_scores method of the CBScorecardConstructor class.
This test verifies that the predict_scores method returns a non-empty
pandas DataFrame with predicted scores per tree and total score.
@@ -368,7 +369,7 @@ def test_woe_mapper_and_gini_scores(credit_data, credit_model):
model, pool = credit_model
# Create scorecard constructor
- constructor = CatBoostScorecardConstructor(model, pool)
+ constructor = CBScorecardConstructor(model, pool)
# Construct scorecard and create points
constructor.construct_scorecard()
@@ -384,15 +385,18 @@ def test_woe_mapper_and_gini_scores(credit_data, credit_model):
# Calculate Gini scores
cb_gini = 2 * roc_auc_score(y, cb_preds) - 1
- leaf_gini = 2 * roc_auc_score(y, leaf_scores) - 1
- woe_gini = 2 * roc_auc_score(y, woe_scores) - 1
- points_gini = 2 * roc_auc_score(y, points_scores) - 1
+ # Negate scores because higher scorecard score = lower risk (opposite of probability)
+ leaf_gini = 2 * roc_auc_score(y, -leaf_scores) - 1
+ points_gini = 2 * roc_auc_score(y, -points_scores) - 1
- # Verify that leaf scores match CatBoost predictions
+ # Verify that leaf scores match CatBoost predictions (after direction alignment)
assert abs(cb_gini - leaf_gini) < 0.01, "Leaf scores Gini should match CatBoost Gini"
- # Verify that WOE scores match points scores
- assert abs(woe_gini - points_gini) < 0.01, "WOE scores Gini should match points Gini"
+ # Verify that WOE scores and points scores have similar absolute Gini
+ # (they may have different signs depending on internal implementation)
+ woe_gini_abs = abs(2 * roc_auc_score(y, woe_scores) - 1)
+ points_gini_abs = abs(points_gini)
+ assert abs(woe_gini_abs - points_gini_abs) < 0.01, "WOE and points Gini magnitudes should match"
@pytest.fixture
@@ -422,8 +426,8 @@ def catboost_pool(sample_data):
def test_initialization():
- """Test initialization of CatBoostScorecardConstructor."""
- constructor = CatBoostScorecardConstructor()
+ """Test initialization of CBScorecardConstructor."""
+ constructor = CBScorecardConstructor()
assert constructor.model is None
assert constructor.pool is None
assert constructor.use_woe is False
@@ -434,7 +438,7 @@ def test_initialization():
def test_fit(trained_model, catboost_pool):
"""Test fitting the constructor."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
assert constructor.model is trained_model
@@ -445,7 +449,7 @@ def test_fit(trained_model, catboost_pool):
def test_get_scorecard(trained_model, catboost_pool):
"""Test getting the scorecard."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
scorecard = constructor.get_scorecard()
@@ -459,7 +463,7 @@ def test_get_scorecard(trained_model, catboost_pool):
def test_get_feature_importance(trained_model, catboost_pool):
"""Test getting feature importance."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
importance = constructor.get_feature_importance()
@@ -471,7 +475,7 @@ def test_get_feature_importance(trained_model, catboost_pool):
def test_predict(trained_model, catboost_pool, sample_data):
"""Test making predictions."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
X, _ = sample_data
@@ -488,7 +492,7 @@ def test_predict(trained_model, catboost_pool, sample_data):
def test_transform(trained_model, catboost_pool, sample_data):
"""Test transforming features."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
X, _ = sample_data
@@ -507,7 +511,7 @@ def test_transform(trained_model, catboost_pool, sample_data):
def test_create_scorecard(trained_model, catboost_pool):
"""Test creating a detailed scorecard."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
# Test without PDO parameters
@@ -525,7 +529,7 @@ def test_create_scorecard(trained_model, catboost_pool):
def test_get_binned_feature_table(trained_model, catboost_pool):
"""Test getting binned feature table."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
binned_table = constructor.get_binned_feature_table()
@@ -538,7 +542,7 @@ def test_get_binned_feature_table(trained_model, catboost_pool):
def test_plot_feature_importance(trained_model, catboost_pool):
"""Test plotting feature importance."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
constructor.fit(trained_model, catboost_pool)
# Test with default parameters
@@ -550,7 +554,7 @@ def test_plot_feature_importance(trained_model, catboost_pool):
def test_error_handling():
"""Test error handling for uninitialized constructor."""
- constructor = CatBoostScorecardConstructor()
+ constructor = CBScorecardConstructor()
with pytest.raises(ValueError, match="Scorecard not built yet"):
constructor.get_scorecard()
@@ -592,7 +596,7 @@ def test_feature_importance_and_multiple_create_points(credit_data, credit_model
model, pool = credit_model
# Create and fit the scorecard constructor
- constructor = CatBoostScorecardConstructor(model, pool)
+ constructor = CBScorecardConstructor(model, pool)
# Construct scorecard
scorecard = constructor.construct_scorecard()
@@ -649,7 +653,8 @@ def test_feature_importance_and_multiple_create_points(credit_data, credit_model
# The points values should be different with the new parameters
assert not np.array_equal(
- points_scorecard["Points"].values, points_scorecard2["Points"].values
+ np.asarray(points_scorecard["Points"].values),
+ np.asarray(points_scorecard2["Points"].values),
)
# Get predictions after second points creation
@@ -704,7 +709,7 @@ def test_pdo_scoring_produces_varied_scores():
model.fit(pool)
# Create scorecard constructor and generate points
- constructor = CatBoostScorecardConstructor(model, pool)
+ constructor = CBScorecardConstructor(model, pool)
# scorecard = constructor.construct_scorecard() # Not used in test
# Create points
diff --git a/tests/test_constructor.py b/tests/test_constructor.py
index ecf2a61..83bb9f4 100644
--- a/tests/test_constructor.py
+++ b/tests/test_constructor.py
@@ -4,9 +4,7 @@
interface for importing scorecard constructors.
"""
-import pytest
-
-from xbooster.constructor import CatBoostScorecardConstructor, XGBScorecardConstructor
+from xbooster.constructor import CBScorecardConstructor, XGBScorecardConstructor
def test_import_xgb_constructor():
@@ -19,12 +17,12 @@ def test_import_xgb_constructor():
def test_import_cb_constructor():
- """Test that CatBoostScorecardConstructor can be imported correctly."""
- assert CatBoostScorecardConstructor is not None
- assert hasattr(CatBoostScorecardConstructor, "__init__")
- assert hasattr(CatBoostScorecardConstructor, "construct_scorecard")
- assert hasattr(CatBoostScorecardConstructor, "create_points")
- assert hasattr(CatBoostScorecardConstructor, "predict_score")
+ """Test that CBScorecardConstructor can be imported correctly."""
+ assert CBScorecardConstructor is not None
+ assert hasattr(CBScorecardConstructor, "__init__")
+ assert hasattr(CBScorecardConstructor, "construct_scorecard")
+ assert hasattr(CBScorecardConstructor, "create_points")
+ assert hasattr(CBScorecardConstructor, "predict_score")
def test_invalid_attribute():
@@ -33,12 +31,10 @@ def test_invalid_attribute():
This test intentionally tries to import a non-existent constructor
to verify that the module raises the correct error.
"""
- with pytest.raises(ImportError) as exc_info:
- # This import is expected to fail - InvalidConstructor does not exist
- from xbooster.constructor import InvalidConstructor # type: ignore[attr-defined] # noqa: F401
- assert "cannot import name 'InvalidConstructor' from 'xbooster.constructor'" in str(
- exc_info.value
- )
+ import importlib
+
+ mod = importlib.import_module("xbooster.constructor")
+ assert not hasattr(mod, "InvalidConstructor")
def test_constructor_all():
@@ -46,6 +42,12 @@ def test_constructor_all():
from xbooster.constructor import __all__
assert "XGBScorecardConstructor" in __all__
+ assert "CBScorecardConstructor" in __all__
+ # Backward compatibility
+ from xbooster.constructor import CatBoostScorecardConstructor
+
+ assert CatBoostScorecardConstructor is CBScorecardConstructor
assert "CatBoostScorecardConstructor" in __all__
- assert len(__all__) == 2
- assert len(__all__) == 2
+ assert (
+ len(__all__) == 3
+ ) # XGBScorecardConstructor, CBScorecardConstructor, CatBoostScorecardConstructor
diff --git a/tests/test_explainer.py b/tests/test_explainer.py
index 4107c79..608ede3 100644
--- a/tests/test_explainer.py
+++ b/tests/test_explainer.py
@@ -106,11 +106,11 @@ def test_plot_importance(self):
# Test with CatBoost constructor (should raise NotImplementedError)
from catboost import CatBoostClassifier
- from xbooster.cb_constructor import CatBoostScorecardConstructor
+ from xbooster.cb_constructor import CBScorecardConstructor
catboost_model = CatBoostClassifier(iterations=10, depth=3, verbose=0)
catboost_model.fit(self.dataset[["feature1", "feature2"]], self.dataset["label"])
- catboost_constructor = CatBoostScorecardConstructor(
+ catboost_constructor = CBScorecardConstructor(
catboost_model, self.dataset[["feature1", "feature2"]], self.dataset["label"]
)
diff --git a/tests/test_finetune.py b/tests/test_finetune.py
new file mode 100644
index 0000000..fa86161
--- /dev/null
+++ b/tests/test_finetune.py
@@ -0,0 +1,207 @@
+"""
+Tests for xbooster.finetuner module.
+
+Tests fine-tuning helpers for XGBoost, LightGBM, and CatBoost with
+both same-feature and expanded-feature scenarios.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+from catboost import CatBoostClassifier
+from lightgbm import LGBMClassifier
+from xgboost import XGBClassifier
+
+from xbooster.finetuner import FineTuneResult, finetune_cb, finetune_lgb, finetune_xgb
+
+
+@pytest.fixture(scope="module")
+def base_data():
+ """Synthetic 100-row dataset with 3 features."""
+ np.random.seed(42)
+ n = 100
+ X = pd.DataFrame(
+ {
+ "feat_a": np.random.randn(n),
+ "feat_b": np.random.randn(n),
+ "feat_c": np.random.randn(n),
+ }
+ )
+ y = pd.Series((X["feat_a"] + X["feat_b"] > 0).astype(int))
+ return X, y
+
+
+@pytest.fixture(scope="module")
+def expanded_data():
+ """Synthetic 100-row dataset with 5 features (base 3 + 2 new)."""
+ np.random.seed(123)
+ n = 100
+ X = pd.DataFrame(
+ {
+ "feat_a": np.random.randn(n),
+ "feat_b": np.random.randn(n),
+ "feat_c": np.random.randn(n),
+ "feat_d": np.random.randn(n),
+ "feat_e": np.random.randn(n),
+ }
+ )
+ y = pd.Series((X["feat_a"] + X["feat_d"] > 0).astype(int))
+ return X, y
+
+
+# --- XGBoost ---
+
+
+@pytest.fixture(scope="module")
+def xgb_base_model(base_data):
+ X, y = base_data
+ model = XGBClassifier(
+ n_estimators=5, max_depth=1, random_state=42, use_label_encoder=False, eval_metric="logloss"
+ )
+ model.fit(X, y)
+ return model
+
+
+def test_finetune_xgb_same_features(xgb_base_model, base_data):
+ X, y = base_data
+ result = finetune_xgb(xgb_base_model, X, y, n_estimators=3)
+
+ assert isinstance(result, FineTuneResult)
+ assert result.n_base_trees == 5
+ assert result.n_total_trees == 5 + 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert result.new_features == []
+ assert result.all_features == ["feat_a", "feat_b", "feat_c"]
+
+ # Model should produce valid predictions
+ preds = result.model.predict_proba(X)
+ assert preds.shape == (len(X), 2)
+ assert np.all((preds >= 0) & (preds <= 1))
+
+
+def test_finetune_xgb_expanded_features(xgb_base_model, expanded_data):
+ X, y = expanded_data
+ result = finetune_xgb(xgb_base_model, X, y, n_estimators=3)
+
+ # Expanded features use warm-start: no base trees carried over
+ assert result.n_base_trees == 0
+ assert result.n_total_trees == 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert set(result.new_features) == {"feat_d", "feat_e"}
+ assert result.all_features[:3] == ["feat_a", "feat_b", "feat_c"]
+
+ preds = result.model.predict_proba(X[result.all_features])
+ assert preds.shape == (len(X), 2)
+
+
+def test_finetune_xgb_custom_learning_rate(xgb_base_model, base_data):
+ X, y = base_data
+ result = finetune_xgb(xgb_base_model, X, y, n_estimators=3, learning_rate=0.01)
+ assert result.n_total_trees == 5 + 3
+
+
+# --- LightGBM ---
+
+
+@pytest.fixture(scope="module")
+def lgb_base_model(base_data):
+ X, y = base_data
+ model = LGBMClassifier(n_estimators=5, max_depth=1, random_state=42, verbose=-1)
+ model.fit(X, y)
+ return model
+
+
+def test_finetune_lgb_same_features(lgb_base_model, base_data):
+ X, y = base_data
+ result = finetune_lgb(lgb_base_model, X, y, n_estimators=3, verbose=-1)
+
+ assert isinstance(result, FineTuneResult)
+ assert result.n_base_trees == 5
+ assert result.n_total_trees == 5 + 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert result.new_features == []
+
+ preds = result.model.predict_proba(X)
+ assert preds.shape == (len(X), 2)
+
+
+def test_finetune_lgb_expanded_features(lgb_base_model, expanded_data):
+ X, y = expanded_data
+ result = finetune_lgb(lgb_base_model, X, y, n_estimators=3, verbose=-1)
+
+ # Expanded features use warm-start: no base trees carried over
+ assert result.n_base_trees == 0
+ assert result.n_total_trees == 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert set(result.new_features) == {"feat_d", "feat_e"}
+
+ preds = result.model.predict_proba(X[result.all_features])
+ assert preds.shape == (len(X), 2)
+
+
+def test_finetune_lgb_custom_learning_rate(lgb_base_model, base_data):
+ X, y = base_data
+ result = finetune_lgb(lgb_base_model, X, y, n_estimators=3, learning_rate=0.01, verbose=-1)
+ assert result.n_total_trees == 5 + 3
+
+
+# --- CatBoost ---
+
+
+@pytest.fixture(scope="module")
+def cb_base_model(base_data):
+ X, y = base_data
+ model = CatBoostClassifier(iterations=5, depth=1, random_seed=42, verbose=0)
+ model.fit(X, y)
+ return model
+
+
+def test_finetune_cb_same_features(cb_base_model, base_data):
+ X, y = base_data
+ result = finetune_cb(cb_base_model, X, y, n_estimators=3, verbose=0)
+
+ assert isinstance(result, FineTuneResult)
+ assert result.n_base_trees == 5
+ assert result.n_total_trees == 5 + 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert result.new_features == []
+
+ preds = result.model.predict_proba(X)
+ assert preds.shape == (len(X), 2)
+
+
+def test_finetune_cb_expanded_features(cb_base_model, expanded_data):
+ X, y = expanded_data
+ result = finetune_cb(cb_base_model, X, y, n_estimators=3, verbose=0)
+
+ # Expanded features use warm-start: no base trees carried over
+ assert result.n_base_trees == 0
+ assert result.n_total_trees == 3
+ assert result.base_features == ["feat_a", "feat_b", "feat_c"]
+ assert set(result.new_features) == {"feat_d", "feat_e"}
+
+ preds = result.model.predict_proba(X[result.all_features])
+ assert preds.shape == (len(X), 2)
+
+
+def test_finetune_cb_custom_learning_rate(cb_base_model, base_data):
+ X, y = base_data
+ result = finetune_cb(cb_base_model, X, y, n_estimators=3, learning_rate=0.01, verbose=0)
+ assert result.n_total_trees == 5 + 3
+
+
+# --- FineTuneResult dataclass ---
+
+
+def test_finetune_result_fields():
+ result = FineTuneResult(
+ model=None,
+ n_base_trees=10,
+ n_total_trees=15,
+ base_features=["a", "b"],
+ all_features=["a", "b", "c"],
+ new_features=["c"],
+ )
+ assert result.n_base_trees == 10
+ assert result.n_total_trees == 15
+ assert result.new_features == ["c"]
diff --git a/tests/test_finetuned_scorecard.py b/tests/test_finetuned_scorecard.py
new file mode 100644
index 0000000..cb41a7a
--- /dev/null
+++ b/tests/test_finetuned_scorecard.py
@@ -0,0 +1,294 @@
+"""
+Tests for fine-tuned scorecard construction.
+
+Tests TreeSource column, from_finetune_result classmethod, summarize_score_sources,
+backward compatibility, and validation for all three constructor classes.
+"""
+
+import numpy as np
+import pandas as pd
+import pytest
+from catboost import CatBoostClassifier
+from lightgbm import LGBMClassifier
+from xgboost import XGBClassifier
+
+from xbooster.cb_constructor import CBScorecardConstructor
+from xbooster.finetuner import finetune_cb, finetune_lgb, finetune_xgb
+from xbooster.lgb_constructor import LGBScorecardConstructor
+from xbooster.xgb_constructor import XGBScorecardConstructor
+
+
+@pytest.fixture(scope="module")
+def base_data():
+ np.random.seed(42)
+ n = 100
+ X = pd.DataFrame(
+ {
+ "feat_a": np.random.randn(n),
+ "feat_b": np.random.randn(n),
+ "feat_c": np.random.randn(n),
+ }
+ )
+ y = pd.Series((X["feat_a"] + X["feat_b"] > 0).astype(int))
+ return X, y
+
+
+# ---- XGBoost fixtures ----
+
+
+@pytest.fixture(scope="module")
+def xgb_finetuned(base_data):
+ X, y = base_data
+ base = XGBClassifier(
+ n_estimators=5, max_depth=1, random_state=42, use_label_encoder=False, eval_metric="logloss"
+ )
+ base.fit(X, y)
+ return finetune_xgb(base, X, y, n_estimators=3)
+
+
+# ---- LightGBM fixtures ----
+
+
+@pytest.fixture(scope="module")
+def lgb_finetuned(base_data):
+ X, y = base_data
+ base = LGBMClassifier(n_estimators=5, max_depth=1, random_state=42, verbose=-1)
+ base.fit(X, y)
+ return finetune_lgb(base, X, y, n_estimators=3, verbose=-1)
+
+
+# ---- CatBoost fixtures ----
+
+
+@pytest.fixture(scope="module")
+def cb_finetuned(base_data):
+ X, y = base_data
+ base = CatBoostClassifier(iterations=5, depth=1, random_seed=42, verbose=0)
+ base.fit(X, y)
+ return finetune_cb(base, X, y, n_estimators=3, verbose=0)
+
+
+# ===========================================================================
+# XGBoost tests
+# ===========================================================================
+
+
+class TestXGBFinetuned:
+ def test_from_finetune_result(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ assert constructor.n_base_trees == 5
+
+ def test_scorecard_has_tree_source(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" in scorecard.columns
+ assert set(scorecard["TreeSource"].unique()) == {"base", "finetuned"}
+
+ def test_tree_source_values(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ base_trees = scorecard[scorecard["TreeSource"] == "base"]["Tree"].unique()
+ ft_trees = scorecard[scorecard["TreeSource"] == "finetuned"]["Tree"].unique()
+ assert all(t < 5 for t in base_trees)
+ assert all(t >= 5 for t in ft_trees)
+
+ def test_create_points_with_finetuned(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ points = constructor.create_points()
+ assert "Points" in points.columns
+
+ def test_predict_score_with_finetuned(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ constructor.create_points()
+ scores = constructor.predict_score(X)
+ assert len(scores) == len(X)
+ assert not scores.isna().any()
+
+ def test_summarize_score_sources(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor.from_finetune_result(xgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ summary = constructor.summarize_score_sources()
+ assert "BaseIV" in summary.columns
+ assert "FinetunedIV" in summary.columns
+ assert "TotalIV" in summary.columns
+ assert "Feature" in summary.columns
+
+ def test_n_base_trees_direct(self, xgb_finetuned, base_data):
+ X, y = base_data
+ constructor = XGBScorecardConstructor(xgb_finetuned.model, X, y, n_base_trees=5)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" in scorecard.columns
+
+ def test_backward_compat_no_n_base_trees(self, base_data):
+ X, y = base_data
+ model = XGBClassifier(
+ n_estimators=5,
+ max_depth=1,
+ random_state=42,
+ use_label_encoder=False,
+ eval_metric="logloss",
+ )
+ model.fit(X, y)
+ constructor = XGBScorecardConstructor(model, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" not in scorecard.columns
+
+ def test_n_base_trees_exceeds_total_raises(self, base_data):
+ X, y = base_data
+ model = XGBClassifier(
+ n_estimators=5,
+ max_depth=1,
+ random_state=42,
+ use_label_encoder=False,
+ eval_metric="logloss",
+ )
+ model.fit(X, y)
+ with pytest.raises(ValueError, match="n_base_trees.*exceeds"):
+ XGBScorecardConstructor(model, X, y, n_base_trees=100)
+
+
+# ===========================================================================
+# LightGBM tests
+# ===========================================================================
+
+
+class TestLGBFinetuned:
+ def test_from_finetune_result(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ assert constructor.n_base_trees == 5
+
+ def test_scorecard_has_tree_source(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" in scorecard.columns
+ assert set(scorecard["TreeSource"].unique()) == {"base", "finetuned"}
+
+ def test_tree_source_values(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ base_trees = scorecard[scorecard["TreeSource"] == "base"]["Tree"].unique()
+ ft_trees = scorecard[scorecard["TreeSource"] == "finetuned"]["Tree"].unique()
+ assert all(t < 5 for t in base_trees)
+ assert all(t >= 5 for t in ft_trees)
+
+ def test_create_points_with_finetuned(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ points = constructor.create_points()
+ assert "Points" in points.columns
+
+ def test_predict_score_with_finetuned(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ constructor.create_points()
+ scores = constructor.predict_score(X)
+ assert len(scores) == len(X)
+ assert not scores.isna().any()
+
+ def test_summarize_score_sources(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(lgb_finetuned, X, y)
+ constructor.construct_scorecard()
+ summary = constructor.summarize_score_sources()
+ assert "BaseIV" in summary.columns
+ assert "FinetunedIV" in summary.columns
+ assert "TotalIV" in summary.columns
+
+ def test_base_score_override(self, lgb_finetuned, base_data):
+ X, y = base_data
+ constructor = LGBScorecardConstructor.from_finetune_result(
+ lgb_finetuned, X, y, base_score=-0.5
+ )
+ assert constructor.base_score == -0.5
+
+ def test_backward_compat_no_n_base_trees(self, base_data):
+ X, y = base_data
+ model = LGBMClassifier(n_estimators=5, max_depth=1, random_state=42, verbose=-1)
+ model.fit(X, y)
+ constructor = LGBScorecardConstructor(model, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" not in scorecard.columns
+
+ def test_n_base_trees_exceeds_total_raises(self, base_data):
+ X, y = base_data
+ model = LGBMClassifier(n_estimators=5, max_depth=1, random_state=42, verbose=-1)
+ model.fit(X, y)
+ with pytest.raises(ValueError, match="n_base_trees.*exceeds"):
+ LGBScorecardConstructor(model, X, y, n_base_trees=100)
+
+
+# ===========================================================================
+# CatBoost tests
+# ===========================================================================
+
+
+class TestCBFinetuned:
+ def test_from_finetune_result(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ assert constructor.n_base_trees == 5
+
+ def test_scorecard_has_tree_source(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" in scorecard.columns
+ assert set(scorecard["TreeSource"].unique()) == {"base", "finetuned"}
+
+ def test_tree_source_values(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ scorecard = constructor.construct_scorecard()
+ base_trees = scorecard[scorecard["TreeSource"] == "base"]["Tree"].unique()
+ ft_trees = scorecard[scorecard["TreeSource"] == "finetuned"]["Tree"].unique()
+ assert all(t < 5 for t in base_trees)
+ assert all(t >= 5 for t in ft_trees)
+
+ def test_create_points_with_finetuned(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ points = constructor.create_points()
+ assert "Points" in points.columns
+
+ def test_predict_score_with_finetuned(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ constructor.create_points()
+ scores = constructor.predict_score(X)
+ assert len(scores) == len(X)
+
+ def test_summarize_score_sources(self, cb_finetuned, base_data):
+ X, y = base_data
+ constructor = CBScorecardConstructor.from_finetune_result(cb_finetuned, X, y)
+ summary = constructor.summarize_score_sources()
+ assert "BaseIV" in summary.columns
+ assert "FinetunedIV" in summary.columns
+ assert "TotalIV" in summary.columns
+
+ def test_backward_compat_no_n_base_trees(self, base_data):
+ X, y = base_data
+ model = CatBoostClassifier(iterations=5, depth=1, random_seed=42, verbose=0)
+ model.fit(X, y)
+ constructor = CBScorecardConstructor(model, X, y)
+ scorecard = constructor.construct_scorecard()
+ assert "TreeSource" not in scorecard.columns
+
+ def test_n_base_trees_exceeds_total_raises(self, base_data):
+ X, y = base_data
+ model = CatBoostClassifier(iterations=5, depth=1, random_seed=42, verbose=0)
+ model.fit(X, y)
+ with pytest.raises(ValueError, match="n_base_trees.*exceeds"):
+ CBScorecardConstructor(model, X, y, n_base_trees=100)
diff --git a/tests/test_lgb_constructor.py b/tests/test_lgb_constructor.py
index 0fae76e..e24c270 100644
--- a/tests/test_lgb_constructor.py
+++ b/tests/test_lgb_constructor.py
@@ -184,7 +184,7 @@ def test_get_leafs_margin(trained_lgb_model, sample_data):
raw_pred = trained_lgb_model.predict(X, raw_score=True)
margin_sum = margins.sum(axis=1).values
- assert np.allclose(margin_sum, raw_pred, rtol=1e-5)
+ assert np.allclose(np.asarray(margin_sum), raw_pred, rtol=1e-5)
def test_construct_scorecard(trained_lgb_model, sample_data):
@@ -228,16 +228,16 @@ def test_two_tree_base_score_validation(sample_data):
)
# Verify: constructor's get_leafs returns same values
- assert np.allclose(margins.iloc[:, 0].values, tree0_pred, rtol=1e-5), (
+ assert np.allclose(np.asarray(margins.iloc[:, 0].values), tree0_pred, rtol=1e-5), (
"Tree 0 margin from get_leafs must match LightGBM API"
)
- assert np.allclose(margins.iloc[:, 1].values, tree1_pred, rtol=1e-5), (
+ assert np.allclose(np.asarray(margins.iloc[:, 1].values), tree1_pred, rtol=1e-5), (
"Tree 1 margin from get_leafs must match LightGBM API"
)
# Verify: margins from get_leafs sum to total
margin_sum = margins.sum(axis=1).values
- assert np.allclose(margin_sum, raw_pred, rtol=1e-5), (
+ assert np.allclose(np.asarray(margin_sum), raw_pred, rtol=1e-5), (
"Margins from get_leafs must sum to total raw prediction"
)
diff --git a/tests/test_xgb_constructor.py b/tests/test_xgb_constructor.py
index 241284c..1d333c7 100644
--- a/tests/test_xgb_constructor.py
+++ b/tests/test_xgb_constructor.py
@@ -291,6 +291,115 @@ def test_construct_scorecard(scorecard_constructor): # pylint: disable=W0621
scorecard = scorecard_constructor.construct_scorecard()
assert isinstance(scorecard, pd.DataFrame)
assert not scorecard.empty
+ # Verify essential columns exist
+ assert "XAddEvidence" in scorecard.columns
+ assert "WOE" in scorecard.columns
+ assert "IV" in scorecard.columns
+
+
+def test_shap_integration(scorecard_constructor): # pylint: disable=W0621
+ """
+ Test SHAP integration in XGBScorecardConstructor.
+
+ Parameters:
+ - scorecard_constructor: An instance of the XGBScorecardConstructor class.
+
+ Returns:
+ - None
+
+ Raises:
+ - AssertionError: If SHAP integration doesn't work correctly.
+ """
+ from xbooster.shap_scorecard import extract_shap_values_xgb
+
+ # Test extract_shap_values from shap_scorecard module
+ X = scorecard_constructor.X # pylint: disable=C0103
+ shap_values = extract_shap_values_xgb(
+ scorecard_constructor.model,
+ X,
+ scorecard_constructor.base_score,
+ scorecard_constructor.enable_categorical,
+ )
+ assert shap_values.shape[0] == X.shape[0] # Same number of samples
+ assert shap_values.shape[1] == X.shape[1] + 1 # Features + base_value
+
+ # Test predict_score with method="shap"
+ scorecard_constructor.construct_scorecard()
+ scorecard_constructor.create_points()
+ shap_score = scorecard_constructor.predict_score(X, method="shap")
+ assert isinstance(shap_score, pd.Series)
+ assert len(shap_score) == len(X)
+
+
+def test_xaddevidence_shap_equivalence(scorecard_constructor): # pylint: disable=W0621
+ """
+ Test that XAddEvidence from the scorecard table relates to feature SHAP values.
+
+ XAddEvidence stores per-tree margin contributions (leaf weights).
+ When adjusted for the base value difference, sum(XAddEvidence) equals sum(Feature SHAP).
+ """
+ import numpy as np
+ from xbooster.shap_scorecard import extract_shap_values_xgb
+
+ X = scorecard_constructor.X # pylint: disable=C0103
+
+ # Build scorecard
+ scorecard = scorecard_constructor.construct_scorecard()
+ assert "XAddEvidence" in scorecard.columns
+
+ # Get feature SHAP values
+ shap_values_full = extract_shap_values_xgb(
+ scorecard_constructor.model,
+ X.head(10),
+ scorecard_constructor.base_score,
+ scorecard_constructor.enable_categorical,
+ )
+ feature_shap_sum = shap_values_full[:, :-1].sum(axis=1) # Sum across features
+ shap_base_value = shap_values_full[0, -1]
+
+ # Get leaf indices for test observations
+ leaf_indices = scorecard_constructor.get_leafs(X.head(10), output_type="leaf_index")
+ n_trees = len(scorecard["Tree"].unique())
+
+ # Compute base value adjustment (distributed across trees)
+ base_adjustment = (scorecard_constructor.base_score - shap_base_value) / n_trees
+
+ # Sum XAddEvidence from table across all trees (with adjustment)
+ table_margin_sum = []
+ for idx in range(10):
+ obs_leafs = leaf_indices.iloc[idx]
+ total_margin = 0.0
+ for tree_idx in range(n_trees):
+ node_idx = obs_leafs.iloc[tree_idx]
+ row = scorecard[(scorecard["Tree"] == tree_idx) & (scorecard["Node"] == node_idx)]
+ if not row.empty:
+ # XAddEvidence + adjustment = equivalent to feature SHAP
+ total_margin += row["XAddEvidence"].iloc[0] + base_adjustment
+ table_margin_sum.append(total_margin)
+ table_margin_arr = np.array(table_margin_sum)
+
+ # Verify that adjusted XAddEvidence sum equals feature SHAP sum
+ assert np.allclose(table_margin_arr, feature_shap_sum, atol=1e-4), (
+ f"Adjusted XAddEvidence sum should equal Feature SHAP sum. "
+ f"Max diff: {np.abs(table_margin_arr - feature_shap_sum).max()}"
+ )
+
+ # Verify scores match when using same scaling approach
+ pdo, target_points, target_odds = 50, 600, 19
+ factor = pdo / np.log(2)
+ offset = target_points - factor * np.log(target_odds)
+ intercept_scaled = factor * shap_base_value
+
+ scores_from_table = np.round(factor * (-table_margin_arr) - intercept_scaled + offset).astype(
+ int
+ )
+ scores_from_feature = np.round(factor * (-feature_shap_sum) - intercept_scaled + offset).astype(
+ int
+ )
+
+ assert np.array_equal(scores_from_table, scores_from_feature), (
+ "Scores from adjusted XAddEvidence should match scores from feature SHAP"
+ )
def test_create_points(scorecard_constructor): # pylint: disable=W0621
diff --git a/tests/test_xgb_regression.py b/tests/test_xgb_regression.py
index f6c268f..c144dd3 100644
--- a/tests/test_xgb_regression.py
+++ b/tests/test_xgb_regression.py
@@ -126,7 +126,7 @@ def test_construct_scorecard_output_structure(self, sample_data, trained_model):
# Verify data types
assert pd.api.types.is_integer_dtype(scorecard["Tree"])
assert pd.api.types.is_integer_dtype(scorecard["Node"])
- assert pd.api.types.is_float_dtype(scorecard["Count"])
+ assert pd.api.types.is_integer_dtype(scorecard["Count"])
assert pd.api.types.is_float_dtype(scorecard["EventRate"])
def test_construct_scorecard_statistical_properties(self, sample_data, trained_model):
@@ -170,7 +170,7 @@ def test_prediction_consistency(self, sample_data, trained_model):
# Verify they match
assert len(scores) == len(X_test)
assert len(detailed_scores) == len(X_test)
- assert np.allclose(scores.values, detailed_scores["Score"].values)
+ assert np.allclose(np.asarray(scores.values), np.asarray(detailed_scores["Score"].values))
def test_leaf_indices_match_scorecard(self, sample_data, trained_model):
"""Test that leaf indices from get_leafs match scorecard construction."""
@@ -231,7 +231,9 @@ def test_margins_sum_to_model_prediction(self, sample_data, trained_model):
model_margins_adjusted = model_margins - constructor.base_score
# They should match closely (allowing for float32 vs float64 precision)
- assert np.allclose(margin_sums.values, model_margins_adjusted, rtol=1e-4, atol=1e-6)
+ assert np.allclose(
+ np.asarray(margin_sums.values), model_margins_adjusted, rtol=1e-4, atol=1e-6
+ )
def test_woe_and_iv_calculations(self, sample_data, trained_model):
"""Test that WOE and IV are calculated correctly."""
diff --git a/typings/catboost/__init__.pyi b/typings/catboost/__init__.pyi
new file mode 100644
index 0000000..55b802a
--- /dev/null
+++ b/typings/catboost/__init__.pyi
@@ -0,0 +1,50 @@
+"""Type stubs for catboost package."""
+
+from typing import Any
+
+import numpy as np
+
+class _CatBoostBase:
+ """Internal CatBoost object exposed via model._object."""
+
+ def _get_tree_count(self) -> int: ...
+ def _get_tree_splits(self, tree_idx: int, pool: "Pool") -> list[str]: ...
+ def _get_tree_leaf_values(self, tree_idx: int) -> list[float]: ...
+ def _get_tree_leaf_counts(self) -> list[int]: ...
+
+class Pool:
+ def __init__(
+ self,
+ data: Any,
+ label: Any = ...,
+ *,
+ cat_features: list[int] | list[str] | None = ...,
+ baseline: Any = ...,
+ ) -> None: ...
+ def get_label(self) -> np.ndarray: ...
+ def get_feature_names(self) -> list[str]: ...
+ _feature_names: list[str] | None
+
+class CatBoostClassifier:
+ tree_count_: int
+ feature_names_: list[str]
+ _object: _CatBoostBase
+
+ def __init__(self, **kwargs: Any) -> None: ...
+ def fit(
+ self,
+ X: Any,
+ y: Any = ...,
+ *,
+ init_model: Any = ...,
+ cat_features: list[int] | list[str] | None = ...,
+ **kwargs: Any,
+ ) -> "CatBoostClassifier": ...
+ def predict(self, data: Any, *, prediction_type: str = ..., **kwargs: Any) -> np.ndarray: ...
+ def predict_proba(self, data: Any, **kwargs: Any) -> np.ndarray: ...
+ def get_all_params(self) -> dict[str, Any]: ...
+ def get_cat_feature_indices(self) -> list[int]: ...
+ def calc_leaf_indexes(self, data: Any) -> np.ndarray: ...
+ def get_feature_importance(self, *, type: str = ..., data: Any = ...) -> np.ndarray: ...
+
+def __getattr__(name: str) -> Any: ...
diff --git a/typings/lightgbm/__init__.pyi b/typings/lightgbm/__init__.pyi
index 2d848ec..4b6dfd2 100644
--- a/typings/lightgbm/__init__.pyi
+++ b/typings/lightgbm/__init__.pyi
@@ -2,21 +2,40 @@
from typing import Any
-class LGBMClassifier:
- """Type stub for LightGBM classifier."""
+import numpy as np
+import pandas as pd
- booster_: Any
+class Booster:
+ def dump_model(self) -> Any: ...
+ def trees_to_dataframe(self) -> pd.DataFrame: ...
+ def num_trees(self) -> int: ...
+ def feature_name(self) -> list[str]: ...
+ def predict(
+ self, data: Any, *, pred_leaf: bool = ..., raw_score: bool = ..., **kwargs: Any
+ ) -> np.ndarray: ...
+
+class LGBMClassifier:
+ booster_: Booster
n_estimators: int
+ learning_rate: float
+ max_depth: int
def __init__(self, *args: Any, **kwargs: Any) -> None: ...
- def fit(self, *args: Any, **kwargs: Any) -> Any: ...
- def predict(self, *args: Any, **kwargs: Any) -> Any: ...
- def predict_proba(self, *args: Any, **kwargs: Any) -> Any: ...
-
-class Booster:
- """Type stub for LightGBM Booster."""
-
- def dump_model(self) -> Any: ...
- def trees_to_dataframe(self) -> Any: ...
+ def fit(
+ self, X: Any, y: Any, *, init_model: Any = ..., init_score: Any = ..., **kwargs: Any
+ ) -> "LGBMClassifier": ...
+ def predict(
+ self,
+ X: Any,
+ *,
+ raw_score: bool = ...,
+ pred_leaf: bool = ...,
+ pred_contrib: bool = ...,
+ start_iteration: int = ...,
+ num_iteration: int = ...,
+ **kwargs: Any,
+ ) -> np.ndarray: ...
+ def predict_proba(self, X: Any, **kwargs: Any) -> np.ndarray: ...
+ def get_params(self, deep: bool = ...) -> dict[str, Any]: ...
def __getattr__(name: str) -> Any: ...
diff --git a/typings/scipy/special.pyi b/typings/scipy/special.pyi
index 74df2d2..f4ac619 100644
--- a/typings/scipy/special.pyi
+++ b/typings/scipy/special.pyi
@@ -2,6 +2,5 @@
import numpy as np
-def expit(x: float | np.ndarray) -> float | np.ndarray:
- """Sigmoid function (expit)."""
- ...
+def expit(x: float | np.ndarray) -> float | np.ndarray: ...
+def logit(x: float | np.ndarray) -> float | np.ndarray: ...
diff --git a/typings/xgboost/__init__.pyi b/typings/xgboost/__init__.pyi
new file mode 100644
index 0000000..644dcb3
--- /dev/null
+++ b/typings/xgboost/__init__.pyi
@@ -0,0 +1,53 @@
+"""Type stubs for xgboost package."""
+
+from typing import Any
+
+import numpy as np
+import pandas as pd
+
+class Booster:
+ feature_names: list[str] | None
+
+ def get_dump(self, *, dump_format: str = ...) -> list[str]: ...
+ def num_boosted_rounds(self) -> int: ...
+ def save_config(self) -> str: ...
+ def trees_to_dataframe(self) -> pd.DataFrame: ...
+ def predict(
+ self,
+ data: "DMatrix",
+ *,
+ output_margin: bool = ...,
+ pred_leaf: bool = ...,
+ pred_contribs: bool = ...,
+ iteration_range: tuple[int, int] = ...,
+ ) -> np.ndarray: ...
+
+class DMatrix:
+ def __init__(
+ self,
+ data: Any,
+ label: Any = ...,
+ *,
+ base_margin: Any = ...,
+ enable_categorical: bool = ...,
+ ) -> None: ...
+ def get_label(self) -> np.ndarray: ...
+
+class XGBClassifier:
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
+ def fit(
+ self, X: Any, y: Any, *, base_margin: Any = ..., xgb_model: Any = ..., **kwargs: Any
+ ) -> "XGBClassifier": ...
+ def predict(self, X: Any, *, output_margin: bool = ..., **kwargs: Any) -> np.ndarray: ...
+ def predict_proba(self, X: Any, **kwargs: Any) -> np.ndarray: ...
+ def get_booster(self) -> Booster: ...
+ def get_params(self, deep: bool = ...) -> dict[str, Any]: ...
+
+class XGBRegressor:
+ def __init__(self, *args: Any, **kwargs: Any) -> None: ...
+ def fit(self, X: Any, y: Any, **kwargs: Any) -> "XGBRegressor": ...
+ def predict(self, X: Any, *, output_margin: bool = ..., **kwargs: Any) -> np.ndarray: ...
+ def get_booster(self) -> Booster: ...
+ def get_params(self, deep: bool = ...) -> dict[str, Any]: ...
+
+def __getattr__(name: str) -> Any: ...
diff --git a/uv.lock b/uv.lock
deleted file mode 100644
index 310a565..0000000
--- a/uv.lock
+++ /dev/null
@@ -1,1533 +0,0 @@
-version = 1
-revision = 1
-requires-python = "==3.10.*"
-
-[[package]]
-name = "appnope"
-version = "0.1.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/35/5d/752690df9ef5b76e169e68d6a129fa6d08a7100ca7f754c89495db3c6019/appnope-0.1.4.tar.gz", hash = "sha256:1de3860566df9caf38f01f86f65e0e13e379af54f9e4bee1e66b48f2efffd1ee", size = 4170 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/81/29/5ecc3a15d5a33e31b26c11426c45c501e439cb865d0bff96315d86443b78/appnope-0.1.4-py2.py3-none-any.whl", hash = "sha256:502575ee11cd7a28c0205f379b525beefebab9d161b7c964670864014ed7213c", size = 4321 },
-]
-
-[[package]]
-name = "astroid"
-version = "3.3.9"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/39/33/536530122a22a7504b159bccaf30a1f76aa19d23028bd8b5009eb9b2efea/astroid-3.3.9.tar.gz", hash = "sha256:622cc8e3048684aa42c820d9d218978021c3c3d174fb03a9f0d615921744f550", size = 398731 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/de/80/c749efbd8eef5ea77c7d6f1956e8fbfb51963b7f93ef79647afd4d9886e3/astroid-3.3.9-py3-none-any.whl", hash = "sha256:d05bfd0acba96a7bd43e222828b7d9bc1e138aaeb0649707908d3702a9831248", size = 275339 },
-]
-
-[[package]]
-name = "asttokens"
-version = "3.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4a/e7/82da0a03e7ba5141f05cce0d302e6eed121ae055e0456ca228bf693984bc/asttokens-3.0.0.tar.gz", hash = "sha256:0dcd8baa8d62b0c1d118b399b2ddba3c4aff271d0d7a9e0d4c1681c79035bbc7", size = 61978 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/25/8a/c46dcc25341b5bce5472c718902eb3d38600a903b14fa6aeecef3f21a46f/asttokens-3.0.0-py3-none-any.whl", hash = "sha256:e3078351a059199dd5138cb1c706e6430c05eff2ff136af5eb4790f9d28932e2", size = 26918 },
-]
-
-[[package]]
-name = "attrs"
-version = "25.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/5a/b0/1367933a8532ee6ff8d63537de4f1177af4bff9f3e829baf7331f595bb24/attrs-25.3.0.tar.gz", hash = "sha256:75d7cefc7fb576747b2c81b4442d4d4a1ce0900973527c011d1030fd3bf4af1b", size = 812032 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/77/06/bb80f5f86020c4551da315d78b3ab75e8228f89f0162f2c3a819e407941a/attrs-25.3.0-py3-none-any.whl", hash = "sha256:427318ce031701fea540783410126f03899a97ffc6f61596ad581ac2e40e3bc3", size = 63815 },
-]
-
-[[package]]
-name = "beautifulsoup4"
-version = "4.13.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "soupsieve" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/d8/e4/0c4c39e18fd76d6a628d4dd8da40543d136ce2d1752bd6eeeab0791f4d6b/beautifulsoup4-4.13.4.tar.gz", hash = "sha256:dbb3c4e1ceae6aefebdaf2423247260cd062430a410e38c66f2baa50a8437195", size = 621067 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/50/cd/30110dc0ffcf3b131156077b90e9f60ed75711223f306da4db08eff8403b/beautifulsoup4-4.13.4-py3-none-any.whl", hash = "sha256:9bbbb14bfde9d79f38b8cd5f8c7c85f4b8f2523190ebed90e950a8dea4cb1c4b", size = 187285 },
-]
-
-[[package]]
-name = "bleach"
-version = "6.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "webencodings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/76/9a/0e33f5054c54d349ea62c277191c020c2d6ef1d65ab2cb1993f91ec846d1/bleach-6.2.0.tar.gz", hash = "sha256:123e894118b8a599fd80d3ec1a6d4cc7ce4e5882b1317a7e1ba69b56e95f991f", size = 203083 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/55/96142937f66150805c25c4d0f31ee4132fd33497753400734f9dfdcbdc66/bleach-6.2.0-py3-none-any.whl", hash = "sha256:117d9c6097a7c3d22fd578fcd8d35ff1e125df6736f554da4e432fdd63f31e5e", size = 163406 },
-]
-
-[package.optional-dependencies]
-css = [
- { name = "tinycss2" },
-]
-
-[[package]]
-name = "catboost"
-version = "1.2.8"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "graphviz" },
- { name = "matplotlib" },
- { name = "numpy" },
- { name = "pandas" },
- { name = "plotly" },
- { name = "scipy" },
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0c/ee/8f146ee0b5c6321d4699edd90a036fe68b2c5fad910fa2b369f14043c192/catboost-1.2.8.tar.gz", hash = "sha256:4a1d1aca5caecd919ec476f72c7abd98a704c24fda35506d4d7d71f77f07cb29", size = 58080776 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b0/3f/e2410e21fdd8a1c30eacd97a4203c0dad0ba35eb0d029bebd0dfc810e699/catboost-1.2.8-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:8409c8a2e547469070d73681aa615b5e0b0d78367203d201b2f2b25c33cdcbad", size = 27810638 },
- { url = "https://files.pythonhosted.org/packages/39/22/53612f0c82a8c8ff60be1f734270dde3626bf2dd581d7ad753e2f3fadfc1/catboost-1.2.8-cp310-cp310-manylinux2014_aarch64.whl", hash = "sha256:063020755d21de4f5434663a9b1d7cc1507c5b9254e2e8cd9cce9cd3b9ba4bbe", size = 98748971 },
- { url = "https://files.pythonhosted.org/packages/1a/94/9c42fd69a7cfbcd3f599b2ce6bff0ca286779cd0f2068f94d51c0ff5dbd6/catboost-1.2.8-cp310-cp310-manylinux2014_x86_64.whl", hash = "sha256:5ac7c03a619d8eb86d70ec5748c1c9f09d4085033e3a760bf2f7c2892513c8a8", size = 99162172 },
- { url = "https://files.pythonhosted.org/packages/4c/71/a05501a74e043b2c3ea5264dce8f5d55f0c84c91900581c93a947f258d64/catboost-1.2.8-cp310-cp310-win_amd64.whl", hash = "sha256:b661840dc65e6ab4e62484dbf1556fed7736bb9196c6b5a3abb003cea39f0f91", size = 102466099 },
-]
-
-[[package]]
-name = "cffi"
-version = "1.17.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "pycparser" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/07/f44ca684db4e4f08a3fdc6eeb9a0d15dc6883efc7b8c90357fdbf74e186c/cffi-1.17.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:df8b1c11f177bc2313ec4b2d46baec87a5f3e71fc8b45dab2ee7cae86d9aba14", size = 182191 },
- { url = "https://files.pythonhosted.org/packages/08/fd/cc2fedbd887223f9f5d170c96e57cbf655df9831a6546c1727ae13fa977a/cffi-1.17.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8f2cdc858323644ab277e9bb925ad72ae0e67f69e804f4898c070998d50b1a67", size = 178592 },
- { url = "https://files.pythonhosted.org/packages/de/cc/4635c320081c78d6ffc2cab0a76025b691a91204f4aa317d568ff9280a2d/cffi-1.17.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:edae79245293e15384b51f88b00613ba9f7198016a5948b5dddf4917d4d26382", size = 426024 },
- { url = "https://files.pythonhosted.org/packages/b6/7b/3b2b250f3aab91abe5f8a51ada1b717935fdaec53f790ad4100fe2ec64d1/cffi-1.17.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45398b671ac6d70e67da8e4224a065cec6a93541bb7aebe1b198a61b58c7b702", size = 448188 },
- { url = "https://files.pythonhosted.org/packages/d3/48/1b9283ebbf0ec065148d8de05d647a986c5f22586b18120020452fff8f5d/cffi-1.17.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ad9413ccdeda48c5afdae7e4fa2192157e991ff761e7ab8fdd8926f40b160cc3", size = 455571 },
- { url = "https://files.pythonhosted.org/packages/40/87/3b8452525437b40f39ca7ff70276679772ee7e8b394934ff60e63b7b090c/cffi-1.17.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5da5719280082ac6bd9aa7becb3938dc9f9cbd57fac7d2871717b1feb0902ab6", size = 436687 },
- { url = "https://files.pythonhosted.org/packages/8d/fb/4da72871d177d63649ac449aec2e8a29efe0274035880c7af59101ca2232/cffi-1.17.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2bb1a08b8008b281856e5971307cc386a8e9c5b625ac297e853d36da6efe9c17", size = 446211 },
- { url = "https://files.pythonhosted.org/packages/ab/a0/62f00bcb411332106c02b663b26f3545a9ef136f80d5df746c05878f8c4b/cffi-1.17.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:045d61c734659cc045141be4bae381a41d89b741f795af1dd018bfb532fd0df8", size = 461325 },
- { url = "https://files.pythonhosted.org/packages/36/83/76127035ed2e7e27b0787604d99da630ac3123bfb02d8e80c633f218a11d/cffi-1.17.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:6883e737d7d9e4899a8a695e00ec36bd4e5e4f18fabe0aca0efe0a4b44cdb13e", size = 438784 },
- { url = "https://files.pythonhosted.org/packages/21/81/a6cd025db2f08ac88b901b745c163d884641909641f9b826e8cb87645942/cffi-1.17.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6b8b4a92e1c65048ff98cfe1f735ef8f1ceb72e3d5f0c25fdb12087a23da22be", size = 461564 },
- { url = "https://files.pythonhosted.org/packages/f8/fe/4d41c2f200c4a457933dbd98d3cf4e911870877bd94d9656cc0fcb390681/cffi-1.17.1-cp310-cp310-win32.whl", hash = "sha256:c9c3d058ebabb74db66e431095118094d06abf53284d9c81f27300d0e0d8bc7c", size = 171804 },
- { url = "https://files.pythonhosted.org/packages/d1/b6/0b0f5ab93b0df4acc49cae758c81fe4e5ef26c3ae2e10cc69249dfd8b3ab/cffi-1.17.1-cp310-cp310-win_amd64.whl", hash = "sha256:0f048dcf80db46f0098ccac01132761580d28e28bc0f78ae0d58048063317e15", size = 181299 },
-]
-
-[[package]]
-name = "cfgv"
-version = "3.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/11/74/539e56497d9bd1d484fd863dd69cbbfa653cd2aa27abfe35653494d85e94/cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560", size = 7114 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c5/55/51844dd50c4fc7a33b653bfaba4c2456f06955289ca770a5dbd5fd267374/cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9", size = 7249 },
-]
-
-[[package]]
-name = "cloudpickle"
-version = "3.1.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/52/39/069100b84d7418bc358d81669d5748efb14b9cceacd2f9c75f550424132f/cloudpickle-3.1.1.tar.gz", hash = "sha256:b216fa8ae4019d5482a8ac3c95d8f6346115d8835911fd4aefd1a445e4242c64", size = 22113 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7e/e8/64c37fadfc2816a7701fa8a6ed8d87327c7d54eacfbfb6edab14a2f2be75/cloudpickle-3.1.1-py3-none-any.whl", hash = "sha256:c8c5a44295039331ee9dad40ba100a9c7297b6f988e50e87ccdf3765a668350e", size = 20992 },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
-]
-
-[[package]]
-name = "comm"
-version = "0.2.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/a8/fb783cb0abe2b5fded9f55e5703015cdf1c9c85b3669087c538dd15a6a86/comm-0.2.2.tar.gz", hash = "sha256:3fd7a84065306e07bea1773df6eb8282de51ba82f77c72f9c85716ab11fe980e", size = 6210 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/75/49e5bfe642f71f272236b5b2d2691cf915a7283cc0ceda56357b61daa538/comm-0.2.2-py3-none-any.whl", hash = "sha256:e6fb86cb70ff661ee8c9c14e7d36d6de3b4066f1441be4063df9c5009f0a64d3", size = 7180 },
-]
-
-[[package]]
-name = "contourpy"
-version = "1.3.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/54/eb9bfc647b19f2009dd5c7f5ec51c4e6ca831725f1aea7a993034f483147/contourpy-1.3.2.tar.gz", hash = "sha256:b6945942715a034c671b7fc54f9588126b0b8bf23db2696e3ca8328f3ff0ab54", size = 13466130 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/12/a3/da4153ec8fe25d263aa48c1a4cbde7f49b59af86f0b6f7862788c60da737/contourpy-1.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ba38e3f9f330af820c4b27ceb4b9c7feee5fe0493ea53a8720f4792667465934", size = 268551 },
- { url = "https://files.pythonhosted.org/packages/2f/6c/330de89ae1087eb622bfca0177d32a7ece50c3ef07b28002de4757d9d875/contourpy-1.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dc41ba0714aa2968d1f8674ec97504a8f7e334f48eeacebcaa6256213acb0989", size = 253399 },
- { url = "https://files.pythonhosted.org/packages/c1/bd/20c6726b1b7f81a8bee5271bed5c165f0a8e1f572578a9d27e2ccb763cb2/contourpy-1.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9be002b31c558d1ddf1b9b415b162c603405414bacd6932d031c5b5a8b757f0d", size = 312061 },
- { url = "https://files.pythonhosted.org/packages/22/fc/a9665c88f8a2473f823cf1ec601de9e5375050f1958cbb356cdf06ef1ab6/contourpy-1.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8d2e74acbcba3bfdb6d9d8384cdc4f9260cae86ed9beee8bd5f54fee49a430b9", size = 351956 },
- { url = "https://files.pythonhosted.org/packages/25/eb/9f0a0238f305ad8fb7ef42481020d6e20cf15e46be99a1fcf939546a177e/contourpy-1.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e259bced5549ac64410162adc973c5e2fb77f04df4a439d00b478e57a0e65512", size = 320872 },
- { url = "https://files.pythonhosted.org/packages/32/5c/1ee32d1c7956923202f00cf8d2a14a62ed7517bdc0ee1e55301227fc273c/contourpy-1.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad687a04bc802cbe8b9c399c07162a3c35e227e2daccf1668eb1f278cb698631", size = 325027 },
- { url = "https://files.pythonhosted.org/packages/83/bf/9baed89785ba743ef329c2b07fd0611d12bfecbedbdd3eeecf929d8d3b52/contourpy-1.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cdd22595308f53ef2f891040ab2b93d79192513ffccbd7fe19be7aa773a5e09f", size = 1306641 },
- { url = "https://files.pythonhosted.org/packages/d4/cc/74e5e83d1e35de2d28bd97033426b450bc4fd96e092a1f7a63dc7369b55d/contourpy-1.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b4f54d6a2defe9f257327b0f243612dd051cc43825587520b1bf74a31e2f6ef2", size = 1374075 },
- { url = "https://files.pythonhosted.org/packages/0c/42/17f3b798fd5e033b46a16f8d9fcb39f1aba051307f5ebf441bad1ecf78f8/contourpy-1.3.2-cp310-cp310-win32.whl", hash = "sha256:f939a054192ddc596e031e50bb13b657ce318cf13d264f095ce9db7dc6ae81c0", size = 177534 },
- { url = "https://files.pythonhosted.org/packages/54/ec/5162b8582f2c994721018d0c9ece9dc6ff769d298a8ac6b6a652c307e7df/contourpy-1.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:c440093bbc8fc21c637c03bafcbef95ccd963bc6e0514ad887932c18ca2a759a", size = 221188 },
- { url = "https://files.pythonhosted.org/packages/33/05/b26e3c6ecc05f349ee0013f0bb850a761016d89cec528a98193a48c34033/contourpy-1.3.2-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:fd93cc7f3139b6dd7aab2f26a90dde0aa9fc264dbf70f6740d498a70b860b82c", size = 265681 },
- { url = "https://files.pythonhosted.org/packages/2b/25/ac07d6ad12affa7d1ffed11b77417d0a6308170f44ff20fa1d5aa6333f03/contourpy-1.3.2-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:107ba8a6a7eec58bb475329e6d3b95deba9440667c4d62b9b6063942b61d7f16", size = 315101 },
- { url = "https://files.pythonhosted.org/packages/8f/4d/5bb3192bbe9d3f27e3061a6a8e7733c9120e203cb8515767d30973f71030/contourpy-1.3.2-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:ded1706ed0c1049224531b81128efbd5084598f18d8a2d9efae833edbd2b40ad", size = 220599 },
-]
-
-[[package]]
-name = "cycler"
-version = "0.12.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321 },
-]
-
-[[package]]
-name = "debugpy"
-version = "1.8.14"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bd/75/087fe07d40f490a78782ff3b0a30e3968936854105487decdb33446d4b0e/debugpy-1.8.14.tar.gz", hash = "sha256:7cd287184318416850aa8b60ac90105837bb1e59531898c07569d197d2ed5322", size = 1641444 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fc/df/156df75a41aaebd97cee9d3870fe68f8001b6c1c4ca023e221cfce69bece/debugpy-1.8.14-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:93fee753097e85623cab1c0e6a68c76308cd9f13ffdf44127e6fab4fbf024339", size = 2076510 },
- { url = "https://files.pythonhosted.org/packages/69/cd/4fc391607bca0996db5f3658762106e3d2427beaef9bfd363fd370a3c054/debugpy-1.8.14-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3d937d93ae4fa51cdc94d3e865f535f185d5f9748efb41d0d49e33bf3365bd79", size = 3559614 },
- { url = "https://files.pythonhosted.org/packages/1a/42/4e6d2b9d63e002db79edfd0cb5656f1c403958915e0e73ab3e9220012eec/debugpy-1.8.14-cp310-cp310-win32.whl", hash = "sha256:c442f20577b38cc7a9aafecffe1094f78f07fb8423c3dddb384e6b8f49fd2987", size = 5208588 },
- { url = "https://files.pythonhosted.org/packages/97/b1/cc9e4e5faadc9d00df1a64a3c2d5c5f4b9df28196c39ada06361c5141f89/debugpy-1.8.14-cp310-cp310-win_amd64.whl", hash = "sha256:f117dedda6d969c5c9483e23f573b38f4e39412845c7bc487b6f2648df30fe84", size = 5241043 },
- { url = "https://files.pythonhosted.org/packages/97/1a/481f33c37ee3ac8040d3d51fc4c4e4e7e61cb08b8bc8971d6032acc2279f/debugpy-1.8.14-py2.py3-none-any.whl", hash = "sha256:5cd9a579d553b6cb9759a7908a41988ee6280b961f24f63336835d9418216a20", size = 5256230 },
-]
-
-[[package]]
-name = "decorator"
-version = "5.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/fa/6d96a0978d19e17b68d634497769987b16c8f4cd0a7a05048bec693caa6b/decorator-5.2.1.tar.gz", hash = "sha256:65f266143752f734b0a7cc83c46f4618af75b8c5911b00ccb61d0ac9b6da0360", size = 56711 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190 },
-]
-
-[[package]]
-name = "defusedxml"
-version = "0.7.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604 },
-]
-
-[[package]]
-name = "dill"
-version = "0.4.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/12/80/630b4b88364e9a8c8c5797f4602d0f76ef820909ee32f0bacb9f90654042/dill-0.4.0.tar.gz", hash = "sha256:0633f1d2df477324f53a895b02c901fb961bdbf65a17122586ea7019292cbcf0", size = 186976 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/50/3d/9373ad9c56321fdab5b41197068e1d8c25883b3fea29dd361f9b55116869/dill-0.4.0-py3-none-any.whl", hash = "sha256:44f54bf6412c2c8464c14e8243eb163690a9800dbe2c367330883b19c7561049", size = 119668 },
-]
-
-[[package]]
-name = "distlib"
-version = "0.3.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0d/dd/1bec4c5ddb504ca60fc29472f3d27e8d4da1257a854e1d96742f15c1d02d/distlib-0.3.9.tar.gz", hash = "sha256:a60f20dea646b8a33f3e7772f74dc0b2d0772d2837ee1342a00645c81edf9403", size = 613923 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 },
-]
-
-[[package]]
-name = "exceptiongroup"
-version = "1.2.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/09/35/2495c4ac46b980e4ca1f6ad6db102322ef3ad2410b79fdde159a4b0f3b92/exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc", size = 28883 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/02/cc/b7e31358aac6ed1ef2bb790a9746ac2c69bcb3c8588b41616914eb106eaf/exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b", size = 16453 },
-]
-
-[[package]]
-name = "executing"
-version = "2.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/91/50/a9d80c47ff289c611ff12e63f7c5d13942c65d68125160cefd768c73e6e4/executing-2.2.0.tar.gz", hash = "sha256:5d108c028108fe2551d1a7b2e8b713341e2cb4fc0aa7dcf966fa4327a5226755", size = 978693 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/7b/8f/c4d9bafc34ad7ad5d8dc16dd1347ee0e507a52c3adb6bfa8887e1c6a26ba/executing-2.2.0-py2.py3-none-any.whl", hash = "sha256:11387150cad388d62750327a53d3339fad4888b39a6fe233c3afbb54ecffd3aa", size = 26702 },
-]
-
-[[package]]
-name = "faker"
-version = "37.1.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "tzdata" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ba/a6/b77f42021308ec8b134502343da882c0905d725a4d661c7adeaf7acaf515/faker-37.1.0.tar.gz", hash = "sha256:ad9dc66a3b84888b837ca729e85299a96b58fdaef0323ed0baace93c9614af06", size = 1875707 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d7/a1/8936bc8e79af80ca38288dd93ed44ed1f9d63beb25447a4c59e746e01f8d/faker-37.1.0-py3-none-any.whl", hash = "sha256:dc2f730be71cb770e9c715b13374d80dbcee879675121ab51f9683d262ae9a1c", size = 1918783 },
-]
-
-[[package]]
-name = "fastjsonschema"
-version = "2.21.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/8b/50/4b769ce1ac4071a1ef6d86b1a3fb56cdc3a37615e8c5519e1af96cdac366/fastjsonschema-2.21.1.tar.gz", hash = "sha256:794d4f0a58f848961ba16af7b9c85a3e88cd360df008c59aac6fc5ae9323b5d4", size = 373939 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/90/2b/0817a2b257fe88725c25589d89aec060581aabf668707a8d03b2e9e0cb2a/fastjsonschema-2.21.1-py3-none-any.whl", hash = "sha256:c9e5b7e908310918cf494a434eeb31384dd84a98b57a30bcb1f535015b554667", size = 23924 },
-]
-
-[[package]]
-name = "filelock"
-version = "3.18.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0a/10/c23352565a6544bdc5353e0b15fc1c563352101f30e24bf500207a54df9a/filelock-3.18.0.tar.gz", hash = "sha256:adbc88eabb99d2fec8c9c1b229b171f18afa655400173ddc653d5d01501fb9f2", size = 18075 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215 },
-]
-
-[[package]]
-name = "fonttools"
-version = "4.57.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/03/2d/a9a0b6e3a0cf6bd502e64fc16d894269011930cabfc89aee20d1635b1441/fonttools-4.57.0.tar.gz", hash = "sha256:727ece10e065be2f9dd239d15dd5d60a66e17eac11aea47d447f9f03fdbc42de", size = 3492448 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/db/17/3ddfd1881878b3f856065130bb603f5922e81ae8a4eb53bce0ea78f765a8/fonttools-4.57.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:babe8d1eb059a53e560e7bf29f8e8f4accc8b6cfb9b5fd10e485bde77e71ef41", size = 2756260 },
- { url = "https://files.pythonhosted.org/packages/26/2b/6957890c52c030b0bf9e0add53e5badab4682c6ff024fac9a332bb2ae063/fonttools-4.57.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:81aa97669cd726349eb7bd43ca540cf418b279ee3caba5e2e295fb4e8f841c02", size = 2284691 },
- { url = "https://files.pythonhosted.org/packages/cc/8e/c043b4081774e5eb06a834cedfdb7d432b4935bc8c4acf27207bdc34dfc4/fonttools-4.57.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0e9618630edd1910ad4f07f60d77c184b2f572c8ee43305ea3265675cbbfe7e", size = 4566077 },
- { url = "https://files.pythonhosted.org/packages/59/bc/e16ae5d9eee6c70830ce11d1e0b23d6018ddfeb28025fda092cae7889c8b/fonttools-4.57.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:34687a5d21f1d688d7d8d416cb4c5b9c87fca8a1797ec0d74b9fdebfa55c09ab", size = 4608729 },
- { url = "https://files.pythonhosted.org/packages/25/13/e557bf10bb38e4e4c436d3a9627aadf691bc7392ae460910447fda5fad2b/fonttools-4.57.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:69ab81b66ebaa8d430ba56c7a5f9abe0183afefd3a2d6e483060343398b13fb1", size = 4759646 },
- { url = "https://files.pythonhosted.org/packages/bc/c9/5e2952214d4a8e31026bf80beb18187199b7001e60e99a6ce19773249124/fonttools-4.57.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d639397de852f2ccfb3134b152c741406752640a266d9c1365b0f23d7b88077f", size = 4941652 },
- { url = "https://files.pythonhosted.org/packages/df/04/e80242b3d9ec91a1f785d949edc277a13ecfdcfae744de4b170df9ed77d8/fonttools-4.57.0-cp310-cp310-win32.whl", hash = "sha256:cc066cb98b912f525ae901a24cd381a656f024f76203bc85f78fcc9e66ae5aec", size = 2159432 },
- { url = "https://files.pythonhosted.org/packages/33/ba/e858cdca275daf16e03c0362aa43734ea71104c3b356b2100b98543dba1b/fonttools-4.57.0-cp310-cp310-win_amd64.whl", hash = "sha256:7a64edd3ff6a7f711a15bd70b4458611fb240176ec11ad8845ccbab4fe6745db", size = 2203869 },
- { url = "https://files.pythonhosted.org/packages/90/27/45f8957c3132917f91aaa56b700bcfc2396be1253f685bd5c68529b6f610/fonttools-4.57.0-py3-none-any.whl", hash = "sha256:3122c604a675513c68bd24c6a8f9091f1c2376d18e8f5fe5a101746c81b3e98f", size = 1093605 },
-]
-
-[[package]]
-name = "graphviz"
-version = "0.20.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/fa/83/5a40d19b8347f017e417710907f824915fba411a9befd092e52746b63e9f/graphviz-0.20.3.zip", hash = "sha256:09d6bc81e6a9fa392e7ba52135a9d49f1ed62526f96499325930e87ca1b5925d", size = 256455 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/00/be/d59db2d1d52697c6adc9eacaf50e8965b6345cc143f671e1ed068818d5cf/graphviz-0.20.3-py3-none-any.whl", hash = "sha256:81f848f2904515d8cd359cc611faba817598d2feaac4027b266aa3eda7b3dde5", size = 47126 },
-]
-
-[[package]]
-name = "identify"
-version = "2.6.9"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/9b/98/a71ab060daec766acc30fb47dfca219d03de34a70d616a79a38c6066c5bf/identify-2.6.9.tar.gz", hash = "sha256:d40dfe3142a1421d8518e3d3985ef5ac42890683e32306ad614a29490abeb6bf", size = 99249 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 },
-]
-
-[[package]]
-name = "iniconfig"
-version = "2.1.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 },
-]
-
-[[package]]
-name = "ipykernel"
-version = "6.29.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "appnope", marker = "sys_platform == 'darwin'" },
- { name = "comm" },
- { name = "debugpy" },
- { name = "ipython" },
- { name = "jupyter-client" },
- { name = "jupyter-core" },
- { name = "matplotlib-inline" },
- { name = "nest-asyncio" },
- { name = "packaging" },
- { name = "psutil" },
- { name = "pyzmq" },
- { name = "tornado" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/e9/5c/67594cb0c7055dc50814b21731c22a601101ea3b1b50a9a1b090e11f5d0f/ipykernel-6.29.5.tar.gz", hash = "sha256:f093a22c4a40f8828f8e330a9c297cb93dcab13bd9678ded6de8e5cf81c56215", size = 163367 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/94/5c/368ae6c01c7628438358e6d337c19b05425727fbb221d2a3c4303c372f42/ipykernel-6.29.5-py3-none-any.whl", hash = "sha256:afdb66ba5aa354b09b91379bac28ae4afebbb30e8b39510c9690afb7a10421b5", size = 117173 },
-]
-
-[[package]]
-name = "ipython"
-version = "8.35.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "decorator" },
- { name = "exceptiongroup" },
- { name = "jedi" },
- { name = "matplotlib-inline" },
- { name = "pexpect", marker = "sys_platform != 'emscripten' and sys_platform != 'win32'" },
- { name = "prompt-toolkit" },
- { name = "pygments" },
- { name = "stack-data" },
- { name = "traitlets" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/0c/77/7d1501e8b539b179936e0d5969b578ed23887be0ab8c63e0120b825bda3e/ipython-8.35.0.tar.gz", hash = "sha256:d200b7d93c3f5883fc36ab9ce28a18249c7706e51347681f80a0aef9895f2520", size = 5605027 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/91/bf/17ffca8c8b011d0bac90adb5d4e720cb3ae1fe5ccfdfc14ca31f827ee320/ipython-8.35.0-py3-none-any.whl", hash = "sha256:e6b7470468ba6f1f0a7b116bb688a3ece2f13e2f94138e508201fad677a788ba", size = 830880 },
-]
-
-[[package]]
-name = "isort"
-version = "5.13.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/87/f9/c1eb8635a24e87ade2efce21e3ce8cd6b8630bb685ddc9cdaca1349b2eb5/isort-5.13.2.tar.gz", hash = "sha256:48fdfcb9face5d58a4f6dde2e72a1fb8dcaf8ab26f95ab49fab84c2ddefb0109", size = 175303 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/b3/8def84f539e7d2289a02f0524b944b15d7c75dab7628bedf1c4f0992029c/isort-5.13.2-py3-none-any.whl", hash = "sha256:8ca5e72a8d85860d5a3fa69b8745237f2939afe12dbf656afbcb47fe72d947a6", size = 92310 },
-]
-
-[[package]]
-name = "jedi"
-version = "0.19.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "parso" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278 },
-]
-
-[[package]]
-name = "jinja2"
-version = "3.1.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "markupsafe" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 },
-]
-
-[[package]]
-name = "joblib"
-version = "1.4.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/64/33/60135848598c076ce4b231e1b1895170f45fbcaeaa2c9d5e38b04db70c35/joblib-1.4.2.tar.gz", hash = "sha256:2382c5816b2636fbd20a09e0f4e9dad4736765fdfb7dca582943b9c1366b3f0e", size = 2116621 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/91/29/df4b9b42f2be0b623cbd5e2140cafcaa2bef0759a00b7b70104dcfe2fb51/joblib-1.4.2-py3-none-any.whl", hash = "sha256:06d478d5674cbc267e7496a410ee875abd68e4340feff4490bcb7afb88060ae6", size = 301817 },
-]
-
-[[package]]
-name = "jsonschema"
-version = "4.23.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "jsonschema-specifications" },
- { name = "referencing" },
- { name = "rpds-py" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/38/2e/03362ee4034a4c917f697890ccd4aec0800ccf9ded7f511971c75451deec/jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4", size = 325778 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462 },
-]
-
-[[package]]
-name = "jsonschema-specifications"
-version = "2024.10.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "referencing" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/10/db/58f950c996c793472e336ff3655b13fbcf1e3b359dcf52dcf3ed3b52c352/jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272", size = 15561 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/0f/8910b19ac0670a0f80ce1008e5e751c4a57e14d2c4c13a482aa6079fa9d6/jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf", size = 18459 },
-]
-
-[[package]]
-name = "jupyter-client"
-version = "8.6.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jupyter-core" },
- { name = "python-dateutil" },
- { name = "pyzmq" },
- { name = "tornado" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/71/22/bf9f12fdaeae18019a468b68952a60fe6dbab5d67cd2a103cac7659b41ca/jupyter_client-8.6.3.tar.gz", hash = "sha256:35b3a0947c4a6e9d589eb97d7d4cd5e90f910ee73101611f01283732bd6d9419", size = 342019 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/11/85/b0394e0b6fcccd2c1eeefc230978a6f8cb0c5df1e4cd3e7625735a0d7d1e/jupyter_client-8.6.3-py3-none-any.whl", hash = "sha256:e8a19cc986cc45905ac3362915f410f3af85424b4c0905e94fa5f2cb08e8f23f", size = 106105 },
-]
-
-[[package]]
-name = "jupyter-core"
-version = "5.7.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "platformdirs" },
- { name = "pywin32", marker = "platform_python_implementation != 'PyPy' and sys_platform == 'win32'" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/00/11/b56381fa6c3f4cc5d2cf54a7dbf98ad9aa0b339ef7a601d6053538b079a7/jupyter_core-5.7.2.tar.gz", hash = "sha256:aa5f8d32bbf6b431ac830496da7392035d6f61b4f54872f15c4bd2a9c3f536d9", size = 87629 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c9/fb/108ecd1fe961941959ad0ee4e12ee7b8b1477247f30b1fdfd83ceaf017f0/jupyter_core-5.7.2-py3-none-any.whl", hash = "sha256:4f7315d2f6b4bcf2e3e7cb6e46772eba760ae459cd1f59d29eb57b0a01bd7409", size = 28965 },
-]
-
-[[package]]
-name = "jupyterlab-pygments"
-version = "0.3.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/90/51/9187be60d989df97f5f0aba133fa54e7300f17616e065d1ada7d7646b6d6/jupyterlab_pygments-0.3.0.tar.gz", hash = "sha256:721aca4d9029252b11cfa9d185e5b5af4d54772bb8072f9b7036f4170054d35d", size = 512900 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b1/dd/ead9d8ea85bf202d90cc513b533f9c363121c7792674f78e0d8a854b63b4/jupyterlab_pygments-0.3.0-py3-none-any.whl", hash = "sha256:841a89020971da1d8693f1a99997aefc5dc424bb1b251fd6322462a1b8842780", size = 15884 },
-]
-
-[[package]]
-name = "kiwisolver"
-version = "1.4.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/82/59/7c91426a8ac292e1cdd53a63b6d9439abd573c875c3f92c146767dd33faf/kiwisolver-1.4.8.tar.gz", hash = "sha256:23d5f023bdc8c7e54eb65f03ca5d5bb25b601eac4d7f1a042888a1f45237987e", size = 97538 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/47/5f/4d8e9e852d98ecd26cdf8eaf7ed8bc33174033bba5e07001b289f07308fd/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88c6f252f6816a73b1f8c904f7bbe02fd67c09a69f7cb8a0eecdbf5ce78e63db", size = 124623 },
- { url = "https://files.pythonhosted.org/packages/1d/70/7f5af2a18a76fe92ea14675f8bd88ce53ee79e37900fa5f1a1d8e0b42998/kiwisolver-1.4.8-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c72941acb7b67138f35b879bbe85be0f6c6a70cab78fe3ef6db9c024d9223e5b", size = 66720 },
- { url = "https://files.pythonhosted.org/packages/c6/13/e15f804a142353aefd089fadc8f1d985561a15358c97aca27b0979cb0785/kiwisolver-1.4.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce2cf1e5688edcb727fdf7cd1bbd0b6416758996826a8be1d958f91880d0809d", size = 65413 },
- { url = "https://files.pythonhosted.org/packages/ce/6d/67d36c4d2054e83fb875c6b59d0809d5c530de8148846b1370475eeeece9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:c8bf637892dc6e6aad2bc6d4d69d08764166e5e3f69d469e55427b6ac001b19d", size = 1650826 },
- { url = "https://files.pythonhosted.org/packages/de/c6/7b9bb8044e150d4d1558423a1568e4f227193662a02231064e3824f37e0a/kiwisolver-1.4.8-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:034d2c891f76bd3edbdb3ea11140d8510dca675443da7304205a2eaa45d8334c", size = 1628231 },
- { url = "https://files.pythonhosted.org/packages/b6/38/ad10d437563063eaaedbe2c3540a71101fc7fb07a7e71f855e93ea4de605/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d47b28d1dfe0793d5e96bce90835e17edf9a499b53969b03c6c47ea5985844c3", size = 1408938 },
- { url = "https://files.pythonhosted.org/packages/52/ce/c0106b3bd7f9e665c5f5bc1e07cc95b5dabd4e08e3dad42dbe2faad467e7/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb158fe28ca0c29f2260cca8c43005329ad58452c36f0edf298204de32a9a3ed", size = 1422799 },
- { url = "https://files.pythonhosted.org/packages/d0/87/efb704b1d75dc9758087ba374c0f23d3254505edaedd09cf9d247f7878b9/kiwisolver-1.4.8-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5536185fce131780ebd809f8e623bf4030ce1b161353166c49a3c74c287897f", size = 1354362 },
- { url = "https://files.pythonhosted.org/packages/eb/b3/fd760dc214ec9a8f208b99e42e8f0130ff4b384eca8b29dd0efc62052176/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:369b75d40abedc1da2c1f4de13f3482cb99e3237b38726710f4a793432b1c5ff", size = 2222695 },
- { url = "https://files.pythonhosted.org/packages/a2/09/a27fb36cca3fc01700687cc45dae7a6a5f8eeb5f657b9f710f788748e10d/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:641f2ddf9358c80faa22e22eb4c9f54bd3f0e442e038728f500e3b978d00aa7d", size = 2370802 },
- { url = "https://files.pythonhosted.org/packages/3d/c3/ba0a0346db35fe4dc1f2f2cf8b99362fbb922d7562e5f911f7ce7a7b60fa/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:d561d2d8883e0819445cfe58d7ddd673e4015c3c57261d7bdcd3710d0d14005c", size = 2334646 },
- { url = "https://files.pythonhosted.org/packages/41/52/942cf69e562f5ed253ac67d5c92a693745f0bed3c81f49fc0cbebe4d6b00/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:1732e065704b47c9afca7ffa272f845300a4eb959276bf6970dc07265e73b605", size = 2467260 },
- { url = "https://files.pythonhosted.org/packages/32/26/2d9668f30d8a494b0411d4d7d4ea1345ba12deb6a75274d58dd6ea01e951/kiwisolver-1.4.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:bcb1ebc3547619c3b58a39e2448af089ea2ef44b37988caf432447374941574e", size = 2288633 },
- { url = "https://files.pythonhosted.org/packages/98/99/0dd05071654aa44fe5d5e350729961e7bb535372935a45ac89a8924316e6/kiwisolver-1.4.8-cp310-cp310-win_amd64.whl", hash = "sha256:89c107041f7b27844179ea9c85d6da275aa55ecf28413e87624d033cf1f6b751", size = 71885 },
- { url = "https://files.pythonhosted.org/packages/6c/fc/822e532262a97442989335394d441cd1d0448c2e46d26d3e04efca84df22/kiwisolver-1.4.8-cp310-cp310-win_arm64.whl", hash = "sha256:b5773efa2be9eb9fcf5415ea3ab70fc785d598729fd6057bea38d539ead28271", size = 65175 },
- { url = "https://files.pythonhosted.org/packages/1f/f9/ae81c47a43e33b93b0a9819cac6723257f5da2a5a60daf46aa5c7226ea85/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:e7a019419b7b510f0f7c9dceff8c5eae2392037eae483a7f9162625233802b0a", size = 60403 },
- { url = "https://files.pythonhosted.org/packages/58/ca/f92b5cb6f4ce0c1ebfcfe3e2e42b96917e16f7090e45b21102941924f18f/kiwisolver-1.4.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:286b18e86682fd2217a48fc6be6b0f20c1d0ed10958d8dc53453ad58d7be0bf8", size = 58657 },
- { url = "https://files.pythonhosted.org/packages/80/28/ae0240f732f0484d3a4dc885d055653c47144bdf59b670aae0ec3c65a7c8/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4191ee8dfd0be1c3666ccbac178c5a05d5f8d689bbe3fc92f3c4abec817f8fe0", size = 84948 },
- { url = "https://files.pythonhosted.org/packages/5d/eb/78d50346c51db22c7203c1611f9b513075f35c4e0e4877c5dde378d66043/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7cd2785b9391f2873ad46088ed7599a6a71e762e1ea33e87514b1a441ed1da1c", size = 81186 },
- { url = "https://files.pythonhosted.org/packages/43/f8/7259f18c77adca88d5f64f9a522792e178b2691f3748817a8750c2d216ef/kiwisolver-1.4.8-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c07b29089b7ba090b6f1a669f1411f27221c3662b3a1b7010e67b59bb5a6f10b", size = 80279 },
- { url = "https://files.pythonhosted.org/packages/3a/1d/50ad811d1c5dae091e4cf046beba925bcae0a610e79ae4c538f996f63ed5/kiwisolver-1.4.8-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:65ea09a5a3faadd59c2ce96dc7bf0f364986a315949dc6374f04396b0d60e09b", size = 71762 },
-]
-
-[[package]]
-name = "lightgbm"
-version = "4.6.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "scipy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/68/0b/a2e9f5c5da7ef047cc60cef37f86185088845e8433e54d2e7ed439cce8a3/lightgbm-4.6.0.tar.gz", hash = "sha256:cb1c59720eb569389c0ba74d14f52351b573af489f230032a1c9f314f8bab7fe", size = 1703705 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f2/75/cffc9962cca296bc5536896b7e65b4a7cdeb8db208e71b9c0133c08f8f7e/lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed", size = 2010151 },
- { url = "https://files.pythonhosted.org/packages/21/1b/550ee378512b78847930f5d74228ca1fdba2a7fbdeaac9aeccc085b0e257/lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad", size = 1592172 },
- { url = "https://files.pythonhosted.org/packages/64/41/4fbde2c3d29e25ee7c41d87df2f2e5eda65b431ee154d4d462c31041846c/lightgbm-4.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4d68712bbd2b57a0b14390cbf9376c1d5ed773fa2e71e099cac588703b590336", size = 3454567 },
- { url = "https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d", size = 3569831 },
- { url = "https://files.pythonhosted.org/packages/5e/23/f8b28ca248bb629b9e08f877dd2965d1994e1674a03d67cd10c5246da248/lightgbm-4.6.0-py3-none-win_amd64.whl", hash = "sha256:37089ee95664b6550a7189d887dbf098e3eadab03537e411f52c63c121e3ba4b", size = 1451509 },
-]
-
-[[package]]
-name = "llvmlite"
-version = "0.44.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/89/6a/95a3d3610d5c75293d5dbbb2a76480d5d4eeba641557b69fe90af6c5b84e/llvmlite-0.44.0.tar.gz", hash = "sha256:07667d66a5d150abed9157ab6c0b9393c9356f229784a4385c02f99e94fc94d4", size = 171880 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/41/75/d4863ddfd8ab5f6e70f4504cf8cc37f4e986ec6910f4ef8502bb7d3c1c71/llvmlite-0.44.0-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:9fbadbfba8422123bab5535b293da1cf72f9f478a65645ecd73e781f962ca614", size = 28132306 },
- { url = "https://files.pythonhosted.org/packages/37/d9/6e8943e1515d2f1003e8278819ec03e4e653e2eeb71e4d00de6cfe59424e/llvmlite-0.44.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cccf8eb28f24840f2689fb1a45f9c0f7e582dd24e088dcf96e424834af11f791", size = 26201096 },
- { url = "https://files.pythonhosted.org/packages/aa/46/8ffbc114def88cc698906bf5acab54ca9fdf9214fe04aed0e71731fb3688/llvmlite-0.44.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7202b678cdf904823c764ee0fe2dfe38a76981f4c1e51715b4cb5abb6cf1d9e8", size = 42361859 },
- { url = "https://files.pythonhosted.org/packages/30/1c/9366b29ab050a726af13ebaae8d0dff00c3c58562261c79c635ad4f5eb71/llvmlite-0.44.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40526fb5e313d7b96bda4cbb2c85cd5374e04d80732dd36a282d72a560bb6408", size = 41184199 },
- { url = "https://files.pythonhosted.org/packages/69/07/35e7c594b021ecb1938540f5bce543ddd8713cff97f71d81f021221edc1b/llvmlite-0.44.0-cp310-cp310-win_amd64.whl", hash = "sha256:41e3839150db4330e1b2716c0be3b5c4672525b4c9005e17c7597f835f351ce2", size = 30332381 },
-]
-
-[[package]]
-name = "markupsafe"
-version = "3.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/04/90/d08277ce111dd22f77149fd1a5d4653eeb3b3eaacbdfcbae5afb2600eebd/MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8", size = 14357 },
- { url = "https://files.pythonhosted.org/packages/04/e1/6e2194baeae0bca1fae6629dc0cbbb968d4d941469cbab11a3872edff374/MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158", size = 12393 },
- { url = "https://files.pythonhosted.org/packages/1d/69/35fa85a8ece0a437493dc61ce0bb6d459dcba482c34197e3efc829aa357f/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579", size = 21732 },
- { url = "https://files.pythonhosted.org/packages/22/35/137da042dfb4720b638d2937c38a9c2df83fe32d20e8c8f3185dbfef05f7/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d", size = 20866 },
- { url = "https://files.pythonhosted.org/packages/29/28/6d029a903727a1b62edb51863232152fd335d602def598dade38996887f0/MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb", size = 20964 },
- { url = "https://files.pythonhosted.org/packages/cc/cd/07438f95f83e8bc028279909d9c9bd39e24149b0d60053a97b2bc4f8aa51/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b", size = 21977 },
- { url = "https://files.pythonhosted.org/packages/29/01/84b57395b4cc062f9c4c55ce0df7d3108ca32397299d9df00fedd9117d3d/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c", size = 21366 },
- { url = "https://files.pythonhosted.org/packages/bd/6e/61ebf08d8940553afff20d1fb1ba7294b6f8d279df9fd0c0db911b4bbcfd/MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171", size = 21091 },
- { url = "https://files.pythonhosted.org/packages/11/23/ffbf53694e8c94ebd1e7e491de185124277964344733c45481f32ede2499/MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50", size = 15065 },
- { url = "https://files.pythonhosted.org/packages/44/06/e7175d06dd6e9172d4a69a72592cb3f7a996a9c396eee29082826449bbc3/MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a", size = 15514 },
-]
-
-[[package]]
-name = "matplotlib"
-version = "3.10.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "contourpy" },
- { name = "cycler" },
- { name = "fonttools" },
- { name = "kiwisolver" },
- { name = "numpy" },
- { name = "packaging" },
- { name = "pillow" },
- { name = "pyparsing" },
- { name = "python-dateutil" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/08/b89867ecea2e305f408fbb417139a8dd941ecf7b23a2e02157c36da546f0/matplotlib-3.10.1.tar.gz", hash = "sha256:e8d2d0e3881b129268585bf4765ad3ee73a4591d77b9a18c214ac7e3a79fb2ba", size = 36743335 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ee/b1/f70e27cf1cd76ce2a5e1aa5579d05afe3236052c6d9b9a96325bc823a17e/matplotlib-3.10.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:ff2ae14910be903f4a24afdbb6d7d3a6c44da210fc7d42790b87aeac92238a16", size = 8163654 },
- { url = "https://files.pythonhosted.org/packages/26/af/5ec3d4636106718bb62503a03297125d4514f98fe818461bd9e6b9d116e4/matplotlib-3.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0721a3fd3d5756ed593220a8b86808a36c5031fce489adb5b31ee6dbb47dd5b2", size = 8037943 },
- { url = "https://files.pythonhosted.org/packages/a1/3d/07f9003a71b698b848c9925d05979ffa94a75cd25d1a587202f0bb58aa81/matplotlib-3.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0673b4b8f131890eb3a1ad058d6e065fb3c6e71f160089b65f8515373394698", size = 8449510 },
- { url = "https://files.pythonhosted.org/packages/12/87/9472d4513ff83b7cd864311821793ab72234fa201ab77310ec1b585d27e2/matplotlib-3.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e875b95ac59a7908978fe307ecdbdd9a26af7fa0f33f474a27fcf8c99f64a19", size = 8586585 },
- { url = "https://files.pythonhosted.org/packages/31/9e/fe74d237d2963adae8608faeb21f778cf246dbbf4746cef87cffbc82c4b6/matplotlib-3.10.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:2589659ea30726284c6c91037216f64a506a9822f8e50592d48ac16a2f29e044", size = 9397911 },
- { url = "https://files.pythonhosted.org/packages/b6/1b/025d3e59e8a4281ab463162ad7d072575354a1916aba81b6a11507dfc524/matplotlib-3.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:a97ff127f295817bc34517255c9db6e71de8eddaab7f837b7d341dee9f2f587f", size = 8052998 },
- { url = "https://files.pythonhosted.org/packages/c8/f6/10adb696d8cbeed2ab4c2e26ecf1c80dd3847bbf3891f4a0c362e0e08a5a/matplotlib-3.10.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:648406f1899f9a818cef8c0231b44dcfc4ff36f167101c3fd1c9151f24220fdc", size = 8158685 },
- { url = "https://files.pythonhosted.org/packages/3f/84/0603d917406072763e7f9bb37747d3d74d7ecd4b943a8c947cc3ae1cf7af/matplotlib-3.10.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:02582304e352f40520727984a5a18f37e8187861f954fea9be7ef06569cf85b4", size = 8035491 },
- { url = "https://files.pythonhosted.org/packages/fd/7d/6a8b31dd07ed856b3eae001c9129670ef75c4698fa1c2a6ac9f00a4a7054/matplotlib-3.10.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d3809916157ba871bcdd33d3493acd7fe3037db5daa917ca6e77975a94cef779", size = 8590087 },
-]
-
-[[package]]
-name = "matplotlib-inline"
-version = "0.1.7"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/99/5b/a36a337438a14116b16480db471ad061c36c3694df7c2084a0da7ba538b7/matplotlib_inline-0.1.7.tar.gz", hash = "sha256:8423b23ec666be3d16e16b60bdd8ac4e86e840ebd1dd11a30b9f117f2fa0ab90", size = 8159 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8f/8e/9ad090d3553c280a8060fbf6e24dc1c0c29704ee7d1c372f0c174aa59285/matplotlib_inline-0.1.7-py3-none-any.whl", hash = "sha256:df192d39a4ff8f21b1895d72e6a13f5fcc5099f00fa84384e0ea28c2cc0653ca", size = 9899 },
-]
-
-[[package]]
-name = "mccabe"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/ff/0ffefdcac38932a54d2b5eed4e0ba8a408f215002cd178ad1df0f2806ff8/mccabe-0.7.0.tar.gz", hash = "sha256:348e0240c33b60bbdf4e523192ef919f28cb2c3d7d5c7794f74009290f236325", size = 9658 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/27/1a/1f68f9ba0c207934b35b86a8ca3aad8395a3d6dd7921c0686e23853ff5a9/mccabe-0.7.0-py2.py3-none-any.whl", hash = "sha256:6c2d30ab6be0e4a46919781807b4f0d834ebdd6c6e3dca0bda5a15f863427b6e", size = 7350 },
-]
-
-[[package]]
-name = "mistune"
-version = "3.1.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c4/79/bda47f7dd7c3c55770478d6d02c9960c430b0cf1773b72366ff89126ea31/mistune-3.1.3.tar.gz", hash = "sha256:a7035c21782b2becb6be62f8f25d3df81ccb4d6fa477a6525b15af06539f02a0", size = 94347 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/01/4d/23c4e4f09da849e127e9f123241946c23c1e30f45a88366879e064211815/mistune-3.1.3-py3-none-any.whl", hash = "sha256:1a32314113cff28aa6432e99e522677c8587fd83e3d51c29b82a52409c842bd9", size = 53410 },
-]
-
-[[package]]
-name = "narwhals"
-version = "1.35.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ee/6a/a98fa5e9d530a428a0cd79d27f059ed65efd3a07aad61a8c93e323c9c20b/narwhals-1.35.0.tar.gz", hash = "sha256:07477d18487fbc940243b69818a177ed7119b737910a8a254fb67688b48a7c96", size = 265784 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/80/b3/5781eb874f04cb1e882a7d93cf30abcb00362a3205c5f3708a7434a1a2ac/narwhals-1.35.0-py3-none-any.whl", hash = "sha256:7562af132fa3f8aaaf34dc96d7ec95bdca29d1c795e8fcf14e01edf1d32122bc", size = 325708 },
-]
-
-[[package]]
-name = "nbclient"
-version = "0.10.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "jupyter-client" },
- { name = "jupyter-core" },
- { name = "nbformat" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/87/66/7ffd18d58eae90d5721f9f39212327695b749e23ad44b3881744eaf4d9e8/nbclient-0.10.2.tar.gz", hash = "sha256:90b7fc6b810630db87a6d0c2250b1f0ab4cf4d3c27a299b0cde78a4ed3fd9193", size = 62424 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/34/6d/e7fa07f03a4a7b221d94b4d586edb754a9b0dc3c9e2c93353e9fa4e0d117/nbclient-0.10.2-py3-none-any.whl", hash = "sha256:4ffee11e788b4a27fabeb7955547e4318a5298f34342a4bfd01f2e1faaeadc3d", size = 25434 },
-]
-
-[[package]]
-name = "nbconvert"
-version = "7.16.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "beautifulsoup4" },
- { name = "bleach", extra = ["css"] },
- { name = "defusedxml" },
- { name = "jinja2" },
- { name = "jupyter-core" },
- { name = "jupyterlab-pygments" },
- { name = "markupsafe" },
- { name = "mistune" },
- { name = "nbclient" },
- { name = "nbformat" },
- { name = "packaging" },
- { name = "pandocfilters" },
- { name = "pygments" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a3/59/f28e15fc47ffb73af68a8d9b47367a8630d76e97ae85ad18271b9db96fdf/nbconvert-7.16.6.tar.gz", hash = "sha256:576a7e37c6480da7b8465eefa66c17844243816ce1ccc372633c6b71c3c0f582", size = 857715 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/cc/9a/cd673b2f773a12c992f41309ef81b99da1690426bd2f96957a7ade0d3ed7/nbconvert-7.16.6-py3-none-any.whl", hash = "sha256:1375a7b67e0c2883678c48e506dc320febb57685e5ee67faa51b18a90f3a712b", size = 258525 },
-]
-
-[[package]]
-name = "nbformat"
-version = "5.10.4"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "fastjsonschema" },
- { name = "jsonschema" },
- { name = "jupyter-core" },
- { name = "traitlets" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/6d/fd/91545e604bc3dad7dca9ed03284086039b294c6b3d75c0d2fa45f9e9caf3/nbformat-5.10.4.tar.gz", hash = "sha256:322168b14f937a5d11362988ecac2a4952d3d8e3a2cbeb2319584631226d5b3a", size = 142749 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a9/82/0340caa499416c78e5d8f5f05947ae4bc3cba53c9f038ab6e9ed964e22f1/nbformat-5.10.4-py3-none-any.whl", hash = "sha256:3b48d6c8fbca4b299bf3982ea7db1af21580e4fec269ad087b9e81588891200b", size = 78454 },
-]
-
-[[package]]
-name = "nest-asyncio"
-version = "1.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195 },
-]
-
-[[package]]
-name = "nodeenv"
-version = "1.9.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314 },
-]
-
-[[package]]
-name = "numba"
-version = "0.61.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "llvmlite" },
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/1c/a0/e21f57604304aa03ebb8e098429222722ad99176a4f979d34af1d1ee80da/numba-0.61.2.tar.gz", hash = "sha256:8750ee147940a6637b80ecf7f95062185ad8726c8c28a2295b8ec1160a196f7d", size = 2820615 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/eb/ca/f470be59552ccbf9531d2d383b67ae0b9b524d435fb4a0d229fef135116e/numba-0.61.2-cp310-cp310-macosx_10_14_x86_64.whl", hash = "sha256:cf9f9fc00d6eca0c23fc840817ce9f439b9f03c8f03d6246c0e7f0cb15b7162a", size = 2775663 },
- { url = "https://files.pythonhosted.org/packages/f5/13/3bdf52609c80d460a3b4acfb9fdb3817e392875c0d6270cf3fd9546f138b/numba-0.61.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ea0247617edcb5dd61f6106a56255baab031acc4257bddaeddb3a1003b4ca3fd", size = 2778344 },
- { url = "https://files.pythonhosted.org/packages/e2/7d/bfb2805bcfbd479f04f835241ecf28519f6e3609912e3a985aed45e21370/numba-0.61.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae8c7a522c26215d5f62ebec436e3d341f7f590079245a2f1008dfd498cc1642", size = 3824054 },
- { url = "https://files.pythonhosted.org/packages/e3/27/797b2004745c92955470c73c82f0e300cf033c791f45bdecb4b33b12bdea/numba-0.61.2-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bd1e74609855aa43661edffca37346e4e8462f6903889917e9f41db40907daa2", size = 3518531 },
- { url = "https://files.pythonhosted.org/packages/b1/c6/c2fb11e50482cb310afae87a997707f6c7d8a48967b9696271347441f650/numba-0.61.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae45830b129c6137294093b269ef0a22998ccc27bf7cf096ab8dcf7bca8946f9", size = 2831612 },
-]
-
-[[package]]
-name = "numpy"
-version = "1.26.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 },
- { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 },
- { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 },
- { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 },
- { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 },
- { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 },
- { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 },
- { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 },
-]
-
-[[package]]
-name = "nvidia-nccl-cu12"
-version = "2.26.2.post1"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/15/c6/b39fd44485cfad53a569ab62ce0c8f583ecf81aa7ca87bacbc8371ec5989/nvidia_nccl_cu12-2.26.2.post1-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:65640d57dd2a20de11048f0a163dd6b6ba8ae97eb3bfc11d80d3c409566bfce7", size = 291669092 },
- { url = "https://files.pythonhosted.org/packages/9f/30/aa24e8e02cd860d80a31ee32cc3a0db9ffb93efb2556705db3ce6c924926/nvidia_nccl_cu12-2.26.2.post1-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ffda04fd1296a90aae76a7e9999d9d3d69ffcec6573109a286fdd2158599114", size = 291662170 },
-]
-
-[[package]]
-name = "packaging"
-version = "24.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
-]
-
-[[package]]
-name = "pandas"
-version = "2.2.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "python-dateutil" },
- { name = "pytz" },
- { name = "tzdata" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/aa/70/c853aec59839bceed032d52010ff5f1b8d87dc3114b762e4ba2727661a3b/pandas-2.2.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:1948ddde24197a0f7add2bdc4ca83bf2b1ef84a1bc8ccffd95eda17fd836ecb5", size = 12580827 },
- { url = "https://files.pythonhosted.org/packages/99/f2/c4527768739ffa4469b2b4fff05aa3768a478aed89a2f271a79a40eee984/pandas-2.2.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:381175499d3802cde0eabbaf6324cce0c4f5d52ca6f8c377c29ad442f50f6348", size = 11303897 },
- { url = "https://files.pythonhosted.org/packages/ed/12/86c1747ea27989d7a4064f806ce2bae2c6d575b950be087837bdfcabacc9/pandas-2.2.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d9c45366def9a3dd85a6454c0e7908f2b3b8e9c138f5dc38fed7ce720d8453ed", size = 66480908 },
- { url = "https://files.pythonhosted.org/packages/44/50/7db2cd5e6373ae796f0ddad3675268c8d59fb6076e66f0c339d61cea886b/pandas-2.2.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:86976a1c5b25ae3f8ccae3a5306e443569ee3c3faf444dfd0f41cda24667ad57", size = 13064210 },
- { url = "https://files.pythonhosted.org/packages/61/61/a89015a6d5536cb0d6c3ba02cebed51a95538cf83472975275e28ebf7d0c/pandas-2.2.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b8661b0238a69d7aafe156b7fa86c44b881387509653fdf857bebc5e4008ad42", size = 16754292 },
- { url = "https://files.pythonhosted.org/packages/ce/0d/4cc7b69ce37fac07645a94e1d4b0880b15999494372c1523508511b09e40/pandas-2.2.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:37e0aced3e8f539eccf2e099f65cdb9c8aa85109b0be6e93e2baff94264bdc6f", size = 14416379 },
- { url = "https://files.pythonhosted.org/packages/31/9e/6ebb433de864a6cd45716af52a4d7a8c3c9aaf3a98368e61db9e69e69a9c/pandas-2.2.3-cp310-cp310-win_amd64.whl", hash = "sha256:56534ce0746a58afaf7942ba4863e0ef81c9c50d3f0ae93e9497d6a41a057645", size = 11598471 },
-]
-
-[[package]]
-name = "pandas-stubs"
-version = "2.2.3.250308"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "types-pytz" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/5a/261f5c67a73e46df2d5984fe7129d66a3ed4864fd7aa9d8721abb3fc802e/pandas_stubs-2.2.3.250308.tar.gz", hash = "sha256:3a6e9daf161f00b85c83772ed3d5cff9522028f07a94817472c07b91f46710fd", size = 103986 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ba/64/ab61d9ca06ff66c07eb804ec27dec1a2be1978b3c3767caaa91e363438cc/pandas_stubs-2.2.3.250308-py3-none-any.whl", hash = "sha256:a377edff3b61f8b268c82499fdbe7c00fdeed13235b8b71d6a1dc347aeddc74d", size = 158053 },
-]
-
-[[package]]
-name = "pandocfilters"
-version = "1.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/70/6f/3dd4940bbe001c06a65f88e36bad298bc7a0de5036115639926b0c5c0458/pandocfilters-1.5.1.tar.gz", hash = "sha256:002b4a555ee4ebc03f8b66307e287fa492e4a77b4ea14d3f934328297bb4939e", size = 8454 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ef/af/4fbc8cab944db5d21b7e2a5b8e9211a03a79852b1157e2c102fcc61ac440/pandocfilters-1.5.1-py2.py3-none-any.whl", hash = "sha256:93be382804a9cdb0a7267585f157e5d1731bbe5545a85b268d6f5fe6232de2bc", size = 8663 },
-]
-
-[[package]]
-name = "parso"
-version = "0.8.4"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/66/94/68e2e17afaa9169cf6412ab0f28623903be73d1b32e208d9e8e541bb086d/parso-0.8.4.tar.gz", hash = "sha256:eb3a7b58240fb99099a345571deecc0f9540ea5f4dd2fe14c2a99d6b281ab92d", size = 400609 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c6/ac/dac4a63f978e4dcb3c6d3a78c4d8e0192a113d288502a1216950c41b1027/parso-0.8.4-py2.py3-none-any.whl", hash = "sha256:a418670a20291dacd2dddc80c377c5c3791378ee1e8d12bffc35420643d43f18", size = 103650 },
-]
-
-[[package]]
-name = "pexpect"
-version = "4.9.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "ptyprocess" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/42/92/cc564bf6381ff43ce1f4d06852fc19a2f11d180f23dc32d9588bee2f149d/pexpect-4.9.0.tar.gz", hash = "sha256:ee7d41123f3c9911050ea2c2dac107568dc43b2d3b0c7557a33212c398ead30f", size = 166450 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772 },
-]
-
-[[package]]
-name = "pillow"
-version = "11.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/af/cb/bb5c01fcd2a69335b86c22142b2bccfc3464087efb7fd382eee5ffc7fdf7/pillow-11.2.1.tar.gz", hash = "sha256:a64dd61998416367b7ef979b73d3a85853ba9bec4c2925f74e588879a58716b6", size = 47026707 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/0d/8b/b158ad57ed44d3cc54db8d68ad7c0a58b8fc0e4c7a3f995f9d62d5b464a1/pillow-11.2.1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:d57a75d53922fc20c165016a20d9c44f73305e67c351bbc60d1adaf662e74047", size = 3198442 },
- { url = "https://files.pythonhosted.org/packages/b1/f8/bb5d956142f86c2d6cc36704943fa761f2d2e4c48b7436fd0a85c20f1713/pillow-11.2.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:127bf6ac4a5b58b3d32fc8289656f77f80567d65660bc46f72c0d77e6600cc95", size = 3030553 },
- { url = "https://files.pythonhosted.org/packages/22/7f/0e413bb3e2aa797b9ca2c5c38cb2e2e45d88654e5b12da91ad446964cfae/pillow-11.2.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b4ba4be812c7a40280629e55ae0b14a0aafa150dd6451297562e1764808bbe61", size = 4405503 },
- { url = "https://files.pythonhosted.org/packages/f3/b4/cc647f4d13f3eb837d3065824aa58b9bcf10821f029dc79955ee43f793bd/pillow-11.2.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8bd62331e5032bc396a93609982a9ab6b411c05078a52f5fe3cc59234a3abd1", size = 4490648 },
- { url = "https://files.pythonhosted.org/packages/c2/6f/240b772a3b35cdd7384166461567aa6713799b4e78d180c555bd284844ea/pillow-11.2.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:562d11134c97a62fe3af29581f083033179f7ff435f78392565a1ad2d1c2c45c", size = 4508937 },
- { url = "https://files.pythonhosted.org/packages/f3/5e/7ca9c815ade5fdca18853db86d812f2f188212792780208bdb37a0a6aef4/pillow-11.2.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:c97209e85b5be259994eb5b69ff50c5d20cca0f458ef9abd835e262d9d88b39d", size = 4599802 },
- { url = "https://files.pythonhosted.org/packages/02/81/c3d9d38ce0c4878a77245d4cf2c46d45a4ad0f93000227910a46caff52f3/pillow-11.2.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0c3e6d0f59171dfa2e25d7116217543310908dfa2770aa64b8f87605f8cacc97", size = 4576717 },
- { url = "https://files.pythonhosted.org/packages/42/49/52b719b89ac7da3185b8d29c94d0e6aec8140059e3d8adcaa46da3751180/pillow-11.2.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc1c3bc53befb6096b84165956e886b1729634a799e9d6329a0c512ab651e579", size = 4654874 },
- { url = "https://files.pythonhosted.org/packages/5b/0b/ede75063ba6023798267023dc0d0401f13695d228194d2242d5a7ba2f964/pillow-11.2.1-cp310-cp310-win32.whl", hash = "sha256:312c77b7f07ab2139924d2639860e084ec2a13e72af54d4f08ac843a5fc9c79d", size = 2331717 },
- { url = "https://files.pythonhosted.org/packages/ed/3c/9831da3edea527c2ed9a09f31a2c04e77cd705847f13b69ca60269eec370/pillow-11.2.1-cp310-cp310-win_amd64.whl", hash = "sha256:9bc7ae48b8057a611e5fe9f853baa88093b9a76303937449397899385da06fad", size = 2676204 },
- { url = "https://files.pythonhosted.org/packages/01/97/1f66ff8a1503d8cbfc5bae4dc99d54c6ec1e22ad2b946241365320caabc2/pillow-11.2.1-cp310-cp310-win_arm64.whl", hash = "sha256:2728567e249cdd939f6cc3d1f049595c66e4187f3c34078cbc0a7d21c47482d2", size = 2414767 },
- { url = "https://files.pythonhosted.org/packages/33/49/c8c21e4255b4f4a2c0c68ac18125d7f5460b109acc6dfdef1a24f9b960ef/pillow-11.2.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:9b7b0d4fd2635f54ad82785d56bc0d94f147096493a79985d0ab57aedd563156", size = 3181727 },
- { url = "https://files.pythonhosted.org/packages/6d/f1/f7255c0838f8c1ef6d55b625cfb286835c17e8136ce4351c5577d02c443b/pillow-11.2.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:aa442755e31c64037aa7c1cb186e0b369f8416c567381852c63444dd666fb772", size = 2999833 },
- { url = "https://files.pythonhosted.org/packages/e2/57/9968114457bd131063da98d87790d080366218f64fa2943b65ac6739abb3/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0d3348c95b766f54b76116d53d4cb171b52992a1027e7ca50c81b43b9d9e363", size = 3437472 },
- { url = "https://files.pythonhosted.org/packages/b2/1b/e35d8a158e21372ecc48aac9c453518cfe23907bb82f950d6e1c72811eb0/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:85d27ea4c889342f7e35f6d56e7e1cb345632ad592e8c51b693d7b7556043ce0", size = 3459976 },
- { url = "https://files.pythonhosted.org/packages/26/da/2c11d03b765efff0ccc473f1c4186dc2770110464f2177efaed9cf6fae01/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:bf2c33d6791c598142f00c9c4c7d47f6476731c31081331664eb26d6ab583e01", size = 3527133 },
- { url = "https://files.pythonhosted.org/packages/79/1a/4e85bd7cadf78412c2a3069249a09c32ef3323650fd3005c97cca7aa21df/pillow-11.2.1-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e616e7154c37669fc1dfc14584f11e284e05d1c650e1c0f972f281c4ccc53193", size = 3571555 },
- { url = "https://files.pythonhosted.org/packages/69/03/239939915216de1e95e0ce2334bf17a7870ae185eb390fab6d706aadbfc0/pillow-11.2.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:39ad2e0f424394e3aebc40168845fee52df1394a4673a6ee512d840d14ab3013", size = 2674713 },
-]
-
-[[package]]
-name = "platformdirs"
-version = "4.3.7"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b6/2d/7d512a3913d60623e7eb945c6d1b4f0bddf1d0b7ada5225274c87e5b53d1/platformdirs-4.3.7.tar.gz", hash = "sha256:eb437d586b6a0986388f0d6f74aa0cde27b48d0e3d66843640bfb6bdcdb6e351", size = 21291 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6d/45/59578566b3275b8fd9157885918fcd0c4d74162928a5310926887b856a51/platformdirs-4.3.7-py3-none-any.whl", hash = "sha256:a03875334331946f13c549dbd8f4bac7a13a50a895a0eb1e8c6a8ace80d40a94", size = 18499 },
-]
-
-[[package]]
-name = "plotly"
-version = "6.0.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "narwhals" },
- { name = "packaging" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/c7/cc/e41b5f697ae403f0b50e47b7af2e36642a193085f553bf7cc1169362873a/plotly-6.0.1.tar.gz", hash = "sha256:dd8400229872b6e3c964b099be699f8d00c489a974f2cfccfad5e8240873366b", size = 8094643 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/02/65/ad2bc85f7377f5cfba5d4466d5474423a3fb7f6a97fd807c06f92dd3e721/plotly-6.0.1-py3-none-any.whl", hash = "sha256:4714db20fea57a435692c548a4eb4fae454f7daddf15f8d8ba7e1045681d7768", size = 14805757 },
-]
-
-[[package]]
-name = "pluggy"
-version = "1.5.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/96/2d/02d4312c973c6050a18b314a5ad0b3210edb65a906f868e31c111dede4a6/pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1", size = 67955 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/5f/e351af9a41f866ac3f1fac4ca0613908d9a41741cfcf2228f4ad853b697d/pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669", size = 20556 },
-]
-
-[[package]]
-name = "pre-commit"
-version = "4.2.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cfgv" },
- { name = "identify" },
- { name = "nodeenv" },
- { name = "pyyaml" },
- { name = "virtualenv" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/08/39/679ca9b26c7bb2999ff122d50faa301e49af82ca9c066ec061cfbc0c6784/pre_commit-4.2.0.tar.gz", hash = "sha256:601283b9757afd87d40c4c4a9b2b5de9637a8ea02eaff7adc2d0fb4e04841146", size = 193424 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707 },
-]
-
-[[package]]
-name = "prek"
-version = "0.2.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a4/8e/ff52d55d27d3756e63f2b9e5b4c4435f7c7f485044df9bd874be01d4bac9/prek-0.2.3.tar.gz", hash = "sha256:a0df9d89618ea8060e766ec21f67bf6c0fac4f320fcbf3073919630b17494996", size = 3007716 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/08/2b/bd188d222d55bd8c63c0bbf736f361b79559457b51553fe7d90ff9950839/prek-0.2.3-py3-none-linux_armv6l.whl", hash = "sha256:216c06989e421f79bf5a9eb3df1c470878438fac1bc0a636e03fc4d614bf219e", size = 4364783 },
- { url = "https://files.pythonhosted.org/packages/c5/a4/24c2dea15242254e3b187f27f419039a88efb56e2d86658b7d2207dba637/prek-0.2.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:37467f2752ce0d4ca6451970d6863211db0ce3f390c74e91db790aad51357eb8", size = 4465162 },
- { url = "https://files.pythonhosted.org/packages/f8/2f/1734a1ae08405ba303a31961b9f80b37d0d0ed6e61bb0df9a2ef4ef5f728/prek-0.2.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2d24575deb40486a1f08799b1e5e17f3be685cbfe4b5071eb93911a9c2728841", size = 4161043 },
- { url = "https://files.pythonhosted.org/packages/d3/c3/b26d307449d805bf8d27d3659f7257395f321399dfea9971a2db27f4e5f8/prek-0.2.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3431ebc218d7a7fc0f708c9eec4027949a081870f4f9fba3a7d65c706db87b72", size = 4343619 },
- { url = "https://files.pythonhosted.org/packages/6a/a2/d1850fb04ae63108e896c3f6e822ca70b76616b9aae4187e7dfb33c83588/prek-0.2.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4520c1e0827775af9112787cf3b005a3deee6baeb5d6ebd71004a20b9a7ea73b", size = 4297871 },
- { url = "https://files.pythonhosted.org/packages/86/a3/028aae3149f69441932ab0bb154068481bd77283502309880326b979480c/prek-0.2.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c8eab4a8d71b978f35a73b7a0e074bbe88c8982e2446a05bfc09fcc046b9a2c2", size = 4581855 },
- { url = "https://files.pythonhosted.org/packages/5b/8e/aa4bd8ab2c4f365d02b963df2c4c1c1f6842b623f97c4411da25b2e6c880/prek-0.2.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e5ba2b767f1ab011a41592dbc2b41ab9c4641e39b763ea0a0519e1d7b83f79ee", size = 5010892 },
- { url = "https://files.pythonhosted.org/packages/81/eb/7f63d8b30fdecbf2722165406a92d56a819d824c0a2c51e308dcbecbbfc2/prek-0.2.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e4afc3876dc812a55dfe7d48c0878bdfadc035697292dbfd6a6bd819d845721b", size = 4938755 },
- { url = "https://files.pythonhosted.org/packages/26/9d/0cc72bb823a078638b50dcca674e8eaa7bbd59dca5ee8f8d5311ec4295a5/prek-0.2.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2de997b640350a4653c267e6e6e681a0902ab436a8a7e659ab1bced213249f79", size = 5061621 },
- { url = "https://files.pythonhosted.org/packages/e5/01/19c1bf227879cee63f8d0644d72728bce43ed96ad3ea355b74cdad77a2d1/prek-0.2.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:030a41c816d2558326f3ad9adab35e412280fc3183a81a23f450b86b9525aa1d", size = 4646050 },
- { url = "https://files.pythonhosted.org/packages/a4/5c/aaa792519e01c7246a41c6a8983b2aa5d52d9b8c7d3d2a54bd0b528ce204/prek-0.2.3-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:7f567b7f2aab8b7dc09e23bab377df69f172b7ccc630bd98fadd103f04878a0e", size = 4356984 },
- { url = "https://files.pythonhosted.org/packages/d4/36/6fe6aecc7302cc870e77d3c8084c62d3db8d3208d0c43aeb6e04de65fa78/prek-0.2.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1193882d0e1fb21b757ccc53a34435d229a2f38ba441edd2c66289532b23d96a", size = 4454158 },
- { url = "https://files.pythonhosted.org/packages/0c/d0/2c1a89a10f8fb08f7eb37bed360b25fef9db4b86a99e8181e77bffab0885/prek-0.2.3-py3-none-musllinux_1_1_armv7l.whl", hash = "sha256:b7a08dacd23791392e9807f4f42631aa1ac53b5907256e4c94af416833b23d00", size = 4276973 },
- { url = "https://files.pythonhosted.org/packages/bf/a8/87e088e97badd0a5c9b79c6837a442a17aff9914d70425ef44ee0819c436/prek-0.2.3-py3-none-musllinux_1_1_i686.whl", hash = "sha256:466eb9ff44575c95b7442751ea86c2f0e9c8c188e7cb79a83134c3a768631c20", size = 4474593 },
- { url = "https://files.pythonhosted.org/packages/62/9c/5a844812f37f7fec3087072fa1dac3da867ba29f71d2cab8df8de396a32e/prek-0.2.3-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:4f6f90c5adda349110a9ff6ca50cda8200d9fb10a6891ca9c89f179cb789c957", size = 4751816 },
- { url = "https://files.pythonhosted.org/packages/1a/23/354fc3934cf09bc0e5d8fa3c52d40c5b686bf2b3faa314b9b6e8dd72d7f7/prek-0.2.3-py3-none-win32.whl", hash = "sha256:c90e15f8617a956a9d2b0c612783eec585a355da685b8f44d338af62ba667c55", size = 4183840 },
- { url = "https://files.pythonhosted.org/packages/4a/07/8a285a062d9d1cf16bbafd1d3782e273e4c2863d521ad84013a1af7d746d/prek-0.2.3-py3-none-win_amd64.whl", hash = "sha256:21cb38ae352772477474cb4c3cd9e9056a43ba7779e634bc23826b6dd01941f5", size = 4748429 },
- { url = "https://files.pythonhosted.org/packages/da/bd/916ccaee27bb3a9b018ad845da25d79594922085df77bcdef842379ad99a/prek-0.2.3-py3-none-win_arm64.whl", hash = "sha256:5c8bbdc6f4313d989327407a516b59e27624ff16c75f939c497c9cacf6e4fbba", size = 4436318 },
-]
-
-[[package]]
-name = "prompt-toolkit"
-version = "3.0.51"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "wcwidth" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/bb/6e/9d084c929dfe9e3bfe0c6a47e31f78a25c54627d64a66e884a8bf5474f1c/prompt_toolkit-3.0.51.tar.gz", hash = "sha256:931a162e3b27fc90c86f1b48bb1fb2c528c2761475e57c9c06de13311c7b54ed", size = 428940 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ce/4f/5249960887b1fbe561d9ff265496d170b55a735b76724f10ef19f9e40716/prompt_toolkit-3.0.51-py3-none-any.whl", hash = "sha256:52742911fde84e2d423e2f9a4cf1de7d7ac4e51958f648d9540e0fb8db077b07", size = 387810 },
-]
-
-[[package]]
-name = "psutil"
-version = "7.0.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/2a/80/336820c1ad9286a4ded7e845b2eccfcb27851ab8ac6abece774a6ff4d3de/psutil-7.0.0.tar.gz", hash = "sha256:7be9c3eba38beccb6495ea33afd982a44074b78f28c434a1f51cc07fd315c456", size = 497003 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ed/e6/2d26234410f8b8abdbf891c9da62bee396583f713fb9f3325a4760875d22/psutil-7.0.0-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:101d71dc322e3cffd7cea0650b09b3d08b8e7c4109dd6809fe452dfd00e58b25", size = 238051 },
- { url = "https://files.pythonhosted.org/packages/04/8b/30f930733afe425e3cbfc0e1468a30a18942350c1a8816acfade80c005c4/psutil-7.0.0-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:39db632f6bb862eeccf56660871433e111b6ea58f2caea825571951d4b6aa3da", size = 239535 },
- { url = "https://files.pythonhosted.org/packages/2a/ed/d362e84620dd22876b55389248e522338ed1bf134a5edd3b8231d7207f6d/psutil-7.0.0-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1fcee592b4c6f146991ca55919ea3d1f8926497a713ed7faaf8225e174581e91", size = 275004 },
- { url = "https://files.pythonhosted.org/packages/bf/b9/b0eb3f3cbcb734d930fdf839431606844a825b23eaf9a6ab371edac8162c/psutil-7.0.0-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b1388a4f6875d7e2aff5c4ca1cc16c545ed41dd8bb596cefea80111db353a34", size = 277986 },
- { url = "https://files.pythonhosted.org/packages/eb/a2/709e0fe2f093556c17fbafda93ac032257242cabcc7ff3369e2cb76a97aa/psutil-7.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5f098451abc2828f7dc6b58d44b532b22f2088f4999a937557b603ce72b1993", size = 279544 },
- { url = "https://files.pythonhosted.org/packages/50/e6/eecf58810b9d12e6427369784efe814a1eec0f492084ce8eb8f4d89d6d61/psutil-7.0.0-cp37-abi3-win32.whl", hash = "sha256:ba3fcef7523064a6c9da440fc4d6bd07da93ac726b5733c29027d7dc95b39d99", size = 241053 },
- { url = "https://files.pythonhosted.org/packages/50/1b/6921afe68c74868b4c9fa424dad3be35b095e16687989ebbb50ce4fceb7c/psutil-7.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:4cf3d4eb1aa9b348dec30105c55cd9b7d4629285735a102beb4441e38db90553", size = 244885 },
-]
-
-[[package]]
-name = "ptyprocess"
-version = "0.7.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/20/e5/16ff212c1e452235a90aeb09066144d0c5a6a8c0834397e03f5224495c4e/ptyprocess-0.7.0.tar.gz", hash = "sha256:5c5d0a3b48ceee0b48485e0c26037c0acd7d29765ca3fbb5cb3831d347423220", size = 70762 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/22/a6/858897256d0deac81a172289110f31629fc4cee19b6f01283303e18c8db3/ptyprocess-0.7.0-py2.py3-none-any.whl", hash = "sha256:4b41f3967fce3af57cc7e94b888626c18bf37a083e3651ca8feeb66d492fef35", size = 13993 },
-]
-
-[[package]]
-name = "pure-eval"
-version = "0.2.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/cd/05/0a34433a064256a578f1783a10da6df098ceaa4a57bbeaa96a6c0352786b/pure_eval-0.2.3.tar.gz", hash = "sha256:5f4e983f40564c576c7c8635ae88db5956bb2229d7e9237d03b3c0b0190eaf42", size = 19752 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8e/37/efad0257dc6e593a18957422533ff0f87ede7c9c6ea010a2177d738fb82f/pure_eval-0.2.3-py3-none-any.whl", hash = "sha256:1db8e35b67b3d218d818ae653e27f06c3aa420901fa7b081ca98cbedc874e0d0", size = 11842 },
-]
-
-[[package]]
-name = "pyarrow"
-version = "19.0.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7f/09/a9046344212690f0632b9c709f9bf18506522feb333c894d0de81d62341a/pyarrow-19.0.1.tar.gz", hash = "sha256:3bf266b485df66a400f282ac0b6d1b500b9d2ae73314a153dbe97d6d5cc8a99e", size = 1129437 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/36/01/b23b514d86b839956238d3f8ef206fd2728eee87ff1b8ce150a5678d9721/pyarrow-19.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:fc28912a2dc924dddc2087679cc8b7263accc71b9ff025a1362b004711661a69", size = 30688914 },
- { url = "https://files.pythonhosted.org/packages/c6/68/218ff7cf4a0652a933e5f2ed11274f724dd43b9813cb18dd72c0a35226a2/pyarrow-19.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:fca15aabbe9b8355800d923cc2e82c8ef514af321e18b437c3d782aa884eaeec", size = 32102866 },
- { url = "https://files.pythonhosted.org/packages/98/01/c295050d183014f4a2eb796d7d2bbfa04b6cccde7258bb68aacf6f18779b/pyarrow-19.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad76aef7f5f7e4a757fddcdcf010a8290958f09e3470ea458c80d26f4316ae89", size = 41147682 },
- { url = "https://files.pythonhosted.org/packages/40/17/a6c3db0b5f3678f33bbb552d2acbc16def67f89a72955b67b0109af23eb0/pyarrow-19.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d03c9d6f2a3dffbd62671ca070f13fc527bb1867b4ec2b98c7eeed381d4f389a", size = 42179192 },
- { url = "https://files.pythonhosted.org/packages/cf/75/c7c8e599300d8cebb6cb339014800e1c720c9db2a3fcb66aa64ec84bac72/pyarrow-19.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:65cf9feebab489b19cdfcfe4aa82f62147218558d8d3f0fc1e9dea0ab8e7905a", size = 40517272 },
- { url = "https://files.pythonhosted.org/packages/ef/c9/68ab123ee1528699c4d5055f645ecd1dd68ff93e4699527249d02f55afeb/pyarrow-19.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:41f9706fbe505e0abc10e84bf3a906a1338905cbbcf1177b71486b03e6ea6608", size = 42069036 },
- { url = "https://files.pythonhosted.org/packages/54/e3/d5cfd7654084e6c0d9c3ce949e5d9e0ccad569ae1e2d5a68a3ec03b2be89/pyarrow-19.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:c6cb2335a411b713fdf1e82a752162f72d4a7b5dbc588e32aa18383318b05866", size = 25277951 },
-]
-
-[[package]]
-name = "pycparser"
-version = "2.22"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
-]
-
-[[package]]
-name = "pygments"
-version = "2.19.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7c/2d/c3338d48ea6cc0feb8446d8e6937e1408088a72a39937982cc6111d17f84/pygments-2.19.1.tar.gz", hash = "sha256:61c16d2a8576dc0649d9f39e089b5f02bcd27fba10d8fb4dcc28173f7a45151f", size = 4968581 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8a/0b/9fcc47d19c48b59121088dd6da2488a49d5f72dacf8262e2790a1d2c7d15/pygments-2.19.1-py3-none-any.whl", hash = "sha256:9ea1544ad55cecf4b8242fab6dd35a93bbce657034b0611ee383099054ab6d8c", size = 1225293 },
-]
-
-[[package]]
-name = "pylint"
-version = "3.3.6"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "astroid" },
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "dill" },
- { name = "isort" },
- { name = "mccabe" },
- { name = "platformdirs" },
- { name = "tomli" },
- { name = "tomlkit" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/69/a7/113d02340afb9dcbb0c8b25454e9538cd08f0ebf3e510df4ed916caa1a89/pylint-3.3.6.tar.gz", hash = "sha256:b634a041aac33706d56a0d217e6587228c66427e20ec21a019bc4cdee48c040a", size = 1519586 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/31/21/9537fc94aee9ec7316a230a49895266cf02d78aa29b0a2efbc39566e0935/pylint-3.3.6-py3-none-any.whl", hash = "sha256:8b7c2d3e86ae3f94fb27703d521dd0b9b6b378775991f504d7c3a6275aa0a6a6", size = 522462 },
-]
-
-[[package]]
-name = "pyparsing"
-version = "3.2.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/bb/22/f1129e69d94ffff626bdb5c835506b3a5b4f3d070f17ea295e12c2c6f60f/pyparsing-3.2.3.tar.gz", hash = "sha256:b9c13f1ab8b3b542f72e28f634bad4de758ab3ce4546e4301970ad6fa77c38be", size = 1088608 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/05/e7/df2285f3d08fee213f2d041540fa4fc9ca6c2d44cf36d3a035bf2a8d2bcc/pyparsing-3.2.3-py3-none-any.whl", hash = "sha256:a749938e02d6fd0b59b356ca504a24982314bb090c383e3cf201c95ef7e2bfcf", size = 111120 },
-]
-
-[[package]]
-name = "pytest"
-version = "8.3.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
- { name = "exceptiongroup" },
- { name = "iniconfig" },
- { name = "packaging" },
- { name = "pluggy" },
- { name = "tomli" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/ae/3c/c9d525a414d506893f0cd8a8d0de7706446213181570cdbd766691164e40/pytest-8.3.5.tar.gz", hash = "sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845", size = 1450891 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/30/3d/64ad57c803f1fa1e963a7946b6e0fea4a70df53c1a7fed304586539c2bac/pytest-8.3.5-py3-none-any.whl", hash = "sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820", size = 343634 },
-]
-
-[[package]]
-name = "python-dateutil"
-version = "2.9.0.post0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "six" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
-]
-
-[[package]]
-name = "pytz"
-version = "2025.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225 },
-]
-
-[[package]]
-name = "pywin32"
-version = "310"
-source = { registry = "https://pypi.org/simple" }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/95/da/a5f38fffbba2fb99aa4aa905480ac4b8e83ca486659ac8c95bce47fb5276/pywin32-310-cp310-cp310-win32.whl", hash = "sha256:6dd97011efc8bf51d6793a82292419eba2c71cf8e7250cfac03bba284454abc1", size = 8848240 },
- { url = "https://files.pythonhosted.org/packages/aa/fe/d873a773324fa565619ba555a82c9dabd677301720f3660a731a5d07e49a/pywin32-310-cp310-cp310-win_amd64.whl", hash = "sha256:c3e78706e4229b915a0821941a84e7ef420bf2b77e08c9dae3c76fd03fd2ae3d", size = 9601854 },
- { url = "https://files.pythonhosted.org/packages/3c/84/1a8e3d7a15490d28a5d816efa229ecb4999cdc51a7c30dd8914f669093b8/pywin32-310-cp310-cp310-win_arm64.whl", hash = "sha256:33babed0cf0c92a6f94cc6cc13546ab24ee13e3e800e61ed87609ab91e4c8213", size = 8522963 },
-]
-
-[[package]]
-name = "pyyaml"
-version = "6.0.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/9b/95/a3fac87cb7158e231b5a6012e438c647e1a87f09f8e0d123acec8ab8bf71/PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086", size = 184199 },
- { url = "https://files.pythonhosted.org/packages/c7/7a/68bd47624dab8fd4afbfd3c48e3b79efe09098ae941de5b58abcbadff5cb/PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf", size = 171758 },
- { url = "https://files.pythonhosted.org/packages/49/ee/14c54df452143b9ee9f0f29074d7ca5516a36edb0b4cc40c3f280131656f/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237", size = 718463 },
- { url = "https://files.pythonhosted.org/packages/4d/61/de363a97476e766574650d742205be468921a7b532aa2499fcd886b62530/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b", size = 719280 },
- { url = "https://files.pythonhosted.org/packages/6b/4e/1523cb902fd98355e2e9ea5e5eb237cbc5f3ad5f3075fa65087aa0ecb669/PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed", size = 751239 },
- { url = "https://files.pythonhosted.org/packages/b7/33/5504b3a9a4464893c32f118a9cc045190a91637b119a9c881da1cf6b7a72/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180", size = 695802 },
- { url = "https://files.pythonhosted.org/packages/5c/20/8347dcabd41ef3a3cdc4f7b7a2aff3d06598c8779faa189cdbf878b626a4/PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68", size = 720527 },
- { url = "https://files.pythonhosted.org/packages/be/aa/5afe99233fb360d0ff37377145a949ae258aaab831bde4792b32650a4378/PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99", size = 144052 },
- { url = "https://files.pythonhosted.org/packages/b5/84/0fa4b06f6d6c958d207620fc60005e241ecedceee58931bb20138e1e5776/PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e", size = 161774 },
-]
-
-[[package]]
-name = "pyzmq"
-version = "26.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cffi", marker = "implementation_name == 'pypy'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b1/11/b9213d25230ac18a71b39b3723494e57adebe36e066397b961657b3b41c1/pyzmq-26.4.0.tar.gz", hash = "sha256:4bd13f85f80962f91a651a7356fe0472791a5f7a92f227822b5acf44795c626d", size = 278293 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/38/b8/af1d814ffc3ff9730f9a970cbf216b6f078e5d251a25ef5201d7bc32a37c/pyzmq-26.4.0-cp310-cp310-macosx_10_15_universal2.whl", hash = "sha256:0329bdf83e170ac133f44a233fc651f6ed66ef8e66693b5af7d54f45d1ef5918", size = 1339238 },
- { url = "https://files.pythonhosted.org/packages/ee/e4/5aafed4886c264f2ea6064601ad39c5fc4e9b6539c6ebe598a859832eeee/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:398a825d2dea96227cf6460ce0a174cf7657d6f6827807d4d1ae9d0f9ae64315", size = 672848 },
- { url = "https://files.pythonhosted.org/packages/79/39/026bf49c721cb42f1ef3ae0ee3d348212a7621d2adb739ba97599b6e4d50/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6d52d62edc96787f5c1dfa6c6ccff9b581cfae5a70d94ec4c8da157656c73b5b", size = 911299 },
- { url = "https://files.pythonhosted.org/packages/03/23/b41f936a9403b8f92325c823c0f264c6102a0687a99c820f1aaeb99c1def/pyzmq-26.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1410c3a3705db68d11eb2424d75894d41cff2f64d948ffe245dd97a9debfebf4", size = 867920 },
- { url = "https://files.pythonhosted.org/packages/c1/3e/2de5928cdadc2105e7c8f890cc5f404136b41ce5b6eae5902167f1d5641c/pyzmq-26.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:7dacb06a9c83b007cc01e8e5277f94c95c453c5851aac5e83efe93e72226353f", size = 862514 },
- { url = "https://files.pythonhosted.org/packages/ce/57/109569514dd32e05a61d4382bc88980c95bfd2f02e58fea47ec0ccd96de1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:6bab961c8c9b3a4dc94d26e9b2cdf84de9918931d01d6ff38c721a83ab3c0ef5", size = 1204494 },
- { url = "https://files.pythonhosted.org/packages/aa/02/dc51068ff2ca70350d1151833643a598625feac7b632372d229ceb4de3e1/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:7a5c09413b924d96af2aa8b57e76b9b0058284d60e2fc3730ce0f979031d162a", size = 1514525 },
- { url = "https://files.pythonhosted.org/packages/48/2a/a7d81873fff0645eb60afaec2b7c78a85a377af8f1d911aff045d8955bc7/pyzmq-26.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:7d489ac234d38e57f458fdbd12a996bfe990ac028feaf6f3c1e81ff766513d3b", size = 1414659 },
- { url = "https://files.pythonhosted.org/packages/ef/ea/813af9c42ae21845c1ccfe495bd29c067622a621e85d7cda6bc437de8101/pyzmq-26.4.0-cp310-cp310-win32.whl", hash = "sha256:dea1c8db78fb1b4b7dc9f8e213d0af3fc8ecd2c51a1d5a3ca1cde1bda034a980", size = 580348 },
- { url = "https://files.pythonhosted.org/packages/20/68/318666a89a565252c81d3fed7f3b4c54bd80fd55c6095988dfa2cd04a62b/pyzmq-26.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:fa59e1f5a224b5e04dc6c101d7186058efa68288c2d714aa12d27603ae93318b", size = 643838 },
- { url = "https://files.pythonhosted.org/packages/91/f8/fb1a15b5f4ecd3e588bfde40c17d32ed84b735195b5c7d1d7ce88301a16f/pyzmq-26.4.0-cp310-cp310-win_arm64.whl", hash = "sha256:a651fe2f447672f4a815e22e74630b6b1ec3a1ab670c95e5e5e28dcd4e69bbb5", size = 559565 },
- { url = "https://files.pythonhosted.org/packages/47/03/96004704a84095f493be8d2b476641f5c967b269390173f85488a53c1c13/pyzmq-26.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:98d948288ce893a2edc5ec3c438fe8de2daa5bbbd6e2e865ec5f966e237084ba", size = 834408 },
- { url = "https://files.pythonhosted.org/packages/e4/7f/68d8f3034a20505db7551cb2260248be28ca66d537a1ac9a257913d778e4/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a9f34f5c9e0203ece706a1003f1492a56c06c0632d86cb77bcfe77b56aacf27b", size = 569580 },
- { url = "https://files.pythonhosted.org/packages/9b/a6/2b0d6801ec33f2b2a19dd8d02e0a1e8701000fec72926e6787363567d30c/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:80c9b48aef586ff8b698359ce22f9508937c799cc1d2c9c2f7c95996f2300c94", size = 798250 },
- { url = "https://files.pythonhosted.org/packages/96/2a/0322b3437de977dcac8a755d6d7ce6ec5238de78e2e2d9353730b297cf12/pyzmq-26.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3f2a5b74009fd50b53b26f65daff23e9853e79aa86e0aa08a53a7628d92d44a", size = 756758 },
- { url = "https://files.pythonhosted.org/packages/c2/33/43704f066369416d65549ccee366cc19153911bec0154da7c6b41fca7e78/pyzmq-26.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:61c5f93d7622d84cb3092d7f6398ffc77654c346545313a3737e266fc11a3beb", size = 555371 },
-]
-
-[[package]]
-name = "referencing"
-version = "0.36.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "attrs" },
- { name = "rpds-py" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/2f/db/98b5c277be99dd18bfd91dd04e1b759cad18d1a338188c936e92f921c7e2/referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa", size = 74744 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/c1/b1/3baf80dc6d2b7bc27a95a67752d0208e410351e3feb4eb78de5f77454d8d/referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0", size = 26775 },
-]
-
-[[package]]
-name = "rpds-py"
-version = "0.24.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/b3/52b213298a0ba7097c7ea96bee95e1947aa84cc816d48cebb539770cdf41/rpds_py-0.24.0.tar.gz", hash = "sha256:772cc1b2cd963e7e17e6cc55fe0371fb9c704d63e44cacec7b9b7f523b78919e", size = 26863 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6a/21/cbc43b220c9deb536b07fbd598c97d463bbb7afb788851891252fc920742/rpds_py-0.24.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:006f4342fe729a368c6df36578d7a348c7c716be1da0a1a0f86e3021f8e98724", size = 377531 },
- { url = "https://files.pythonhosted.org/packages/42/15/cc4b09ef160483e49c3aab3b56f3d375eadf19c87c48718fb0147e86a446/rpds_py-0.24.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2d53747da70a4e4b17f559569d5f9506420966083a31c5fbd84e764461c4444b", size = 362273 },
- { url = "https://files.pythonhosted.org/packages/8c/a2/67718a188a88dbd5138d959bed6efe1cc7413a4caa8283bd46477ed0d1ad/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8acd55bd5b071156bae57b555f5d33697998752673b9de554dd82f5b5352727", size = 388111 },
- { url = "https://files.pythonhosted.org/packages/e5/e6/cbf1d3163405ad5f4a1a6d23f80245f2204d0c743b18525f34982dec7f4d/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7e80d375134ddb04231a53800503752093dbb65dad8dabacce2c84cccc78e964", size = 394447 },
- { url = "https://files.pythonhosted.org/packages/21/bb/4fe220ccc8a549b38b9e9cec66212dc3385a82a5ee9e37b54411cce4c898/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:60748789e028d2a46fc1c70750454f83c6bdd0d05db50f5ae83e2db500b34da5", size = 448028 },
- { url = "https://files.pythonhosted.org/packages/a5/41/d2d6e0fd774818c4cadb94185d30cf3768de1c2a9e0143fc8bc6ce59389e/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6e1daf5bf6c2be39654beae83ee6b9a12347cb5aced9a29eecf12a2d25fff664", size = 447410 },
- { url = "https://files.pythonhosted.org/packages/a7/a7/6d04d438f53d8bb2356bb000bea9cf5c96a9315e405b577117e344cc7404/rpds_py-0.24.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b221c2457d92a1fb3c97bee9095c874144d196f47c038462ae6e4a14436f7bc", size = 389531 },
- { url = "https://files.pythonhosted.org/packages/23/be/72e6df39bd7ca5a66799762bf54d8e702483fdad246585af96723109d486/rpds_py-0.24.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:66420986c9afff67ef0c5d1e4cdc2d0e5262f53ad11e4f90e5e22448df485bf0", size = 420099 },
- { url = "https://files.pythonhosted.org/packages/8c/c9/ca100cd4688ee0aa266197a5cb9f685231676dd7d573041ca53787b23f4e/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:43dba99f00f1d37b2a0265a259592d05fcc8e7c19d140fe51c6e6f16faabeb1f", size = 564950 },
- { url = "https://files.pythonhosted.org/packages/05/98/908cd95686d33b3ac8ac2e582d7ae38e2c3aa2c0377bf1f5663bafd1ffb2/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:a88c0d17d039333a41d9bf4616bd062f0bd7aa0edeb6cafe00a2fc2a804e944f", size = 591778 },
- { url = "https://files.pythonhosted.org/packages/7b/ac/e143726f1dd3215efcb974b50b03bd08a8a1556b404a0a7872af6d197e57/rpds_py-0.24.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc31e13ce212e14a539d430428cd365e74f8b2d534f8bc22dd4c9c55b277b875", size = 560421 },
- { url = "https://files.pythonhosted.org/packages/60/28/add1c1d2fcd5aa354f7225d036d4492261759a22d449cff14841ef36a514/rpds_py-0.24.0-cp310-cp310-win32.whl", hash = "sha256:fc2c1e1b00f88317d9de6b2c2b39b012ebbfe35fe5e7bef980fd2a91f6100a07", size = 222089 },
- { url = "https://files.pythonhosted.org/packages/b0/ac/81f8066c6de44c507caca488ba336ae30d35d57f61fe10578824d1a70196/rpds_py-0.24.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0145295ca415668420ad142ee42189f78d27af806fcf1f32a18e51d47dd2052", size = 234622 },
- { url = "https://files.pythonhosted.org/packages/99/48/11dae46d0c7f7e156ca0971a83f89c510af0316cd5d42c771b7cef945f0c/rpds_py-0.24.0-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:619ca56a5468f933d940e1bf431c6f4e13bef8e688698b067ae68eb4f9b30e3a", size = 378224 },
- { url = "https://files.pythonhosted.org/packages/33/18/e8398d255369e35d312942f3bb8ecaff013c44968904891be2ab63b3aa94/rpds_py-0.24.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:4b28e5122829181de1898c2c97f81c0b3246d49f585f22743a1246420bb8d399", size = 363252 },
- { url = "https://files.pythonhosted.org/packages/17/39/dd73ba691f4df3e6834bf982de214086ac3359ab3ac035adfb30041570e3/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8e5ab32cf9eb3647450bc74eb201b27c185d3857276162c101c0f8c6374e098", size = 388871 },
- { url = "https://files.pythonhosted.org/packages/2f/2e/da0530b25cabd0feca2a759b899d2df325069a94281eeea8ac44c6cfeff7/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:208b3a70a98cf3710e97cabdc308a51cd4f28aa6e7bb11de3d56cd8b74bab98d", size = 394766 },
- { url = "https://files.pythonhosted.org/packages/4c/ee/dd1c5040a431beb40fad4a5d7868acf343444b0bc43e627c71df2506538b/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bbc4362e06f950c62cad3d4abf1191021b2ffaf0b31ac230fbf0526453eee75e", size = 448712 },
- { url = "https://files.pythonhosted.org/packages/f5/ec/6b93ffbb686be948e4d91ec76f4e6757f8551034b2a8176dd848103a1e34/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ebea2821cdb5f9fef44933617be76185b80150632736f3d76e54829ab4a3b4d1", size = 447150 },
- { url = "https://files.pythonhosted.org/packages/55/d5/a1c23760adad85b432df074ced6f910dd28f222b8c60aeace5aeb9a6654e/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4df06c35465ef4d81799999bba810c68d29972bf1c31db61bfdb81dd9d5bb", size = 390662 },
- { url = "https://files.pythonhosted.org/packages/a5/f3/419cb1f9bfbd3a48c256528c156e00f3349e3edce5ad50cbc141e71f66a5/rpds_py-0.24.0-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d3aa13bdf38630da298f2e0d77aca967b200b8cc1473ea05248f6c5e9c9bdb44", size = 421351 },
- { url = "https://files.pythonhosted.org/packages/98/8e/62d1a55078e5ede0b3b09f35e751fa35924a34a0d44d7c760743383cd54a/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:041f00419e1da7a03c46042453598479f45be3d787eb837af382bfc169c0db33", size = 566074 },
- { url = "https://files.pythonhosted.org/packages/fc/69/b7d1003166d78685da032b3c4ff1599fa536a3cfe6e5ce2da87c9c431906/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:d8754d872a5dfc3c5bf9c0e059e8107451364a30d9fd50f1f1a85c4fb9481164", size = 592398 },
- { url = "https://files.pythonhosted.org/packages/ea/a8/1c98bc99338c37faadd28dd667d336df7409d77b4da999506a0b6b1c0aa2/rpds_py-0.24.0-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:896c41007931217a343eff197c34513c154267636c8056fb409eafd494c3dcdc", size = 561114 },
- { url = "https://files.pythonhosted.org/packages/2b/41/65c91443685a4c7b5f1dd271beadc4a3e063d57c3269221548dd9416e15c/rpds_py-0.24.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:92558d37d872e808944c3c96d0423b8604879a3d1c86fdad508d7ed91ea547d5", size = 235548 },
-]
-
-[[package]]
-name = "ruff"
-version = "0.11.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d9/11/bcef6784c7e5d200b8a1f5c2ddf53e5da0efec37e6e5a44d163fb97e04ba/ruff-0.11.6.tar.gz", hash = "sha256:bec8bcc3ac228a45ccc811e45f7eb61b950dbf4cf31a67fa89352574b01c7d79", size = 4010053 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/1f/8848b625100ebcc8740c8bac5b5dd8ba97dd4ee210970e98832092c1635b/ruff-0.11.6-py3-none-linux_armv6l.whl", hash = "sha256:d84dcbe74cf9356d1bdb4a78cf74fd47c740bf7bdeb7529068f69b08272239a1", size = 10248105 },
- { url = "https://files.pythonhosted.org/packages/e0/47/c44036e70c6cc11e6ee24399c2a1e1f1e99be5152bd7dff0190e4b325b76/ruff-0.11.6-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9bc583628e1096148011a5d51ff3c836f51899e61112e03e5f2b1573a9b726de", size = 11001494 },
- { url = "https://files.pythonhosted.org/packages/ed/5b/170444061650202d84d316e8f112de02d092bff71fafe060d3542f5bc5df/ruff-0.11.6-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f2959049faeb5ba5e3b378709e9d1bf0cab06528b306b9dd6ebd2a312127964a", size = 10352151 },
- { url = "https://files.pythonhosted.org/packages/ff/91/f02839fb3787c678e112c8865f2c3e87cfe1744dcc96ff9fc56cfb97dda2/ruff-0.11.6-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:63c5d4e30d9d0de7fedbfb3e9e20d134b73a30c1e74b596f40f0629d5c28a193", size = 10541951 },
- { url = "https://files.pythonhosted.org/packages/9e/f3/c09933306096ff7a08abede3cc2534d6fcf5529ccd26504c16bf363989b5/ruff-0.11.6-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:26a4b9a4e1439f7d0a091c6763a100cef8fbdc10d68593df6f3cfa5abdd9246e", size = 10079195 },
- { url = "https://files.pythonhosted.org/packages/e0/0d/a87f8933fccbc0d8c653cfbf44bedda69c9582ba09210a309c066794e2ee/ruff-0.11.6-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b5edf270223dd622218256569636dc3e708c2cb989242262fe378609eccf1308", size = 11698918 },
- { url = "https://files.pythonhosted.org/packages/52/7d/8eac0bd083ea8a0b55b7e4628428203441ca68cd55e0b67c135a4bc6e309/ruff-0.11.6-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f55844e818206a9dd31ff27f91385afb538067e2dc0beb05f82c293ab84f7d55", size = 12319426 },
- { url = "https://files.pythonhosted.org/packages/c2/dc/d0c17d875662d0c86fadcf4ca014ab2001f867621b793d5d7eef01b9dcce/ruff-0.11.6-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d8f782286c5ff562e4e00344f954b9320026d8e3fae2ba9e6948443fafd9ffc", size = 11791012 },
- { url = "https://files.pythonhosted.org/packages/f9/f3/81a1aea17f1065449a72509fc7ccc3659cf93148b136ff2a8291c4bc3ef1/ruff-0.11.6-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01c63ba219514271cee955cd0adc26a4083df1956d57847978383b0e50ffd7d2", size = 13949947 },
- { url = "https://files.pythonhosted.org/packages/61/9f/a3e34de425a668284e7024ee6fd41f452f6fa9d817f1f3495b46e5e3a407/ruff-0.11.6-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15adac20ef2ca296dd3d8e2bedc6202ea6de81c091a74661c3666e5c4c223ff6", size = 11471753 },
- { url = "https://files.pythonhosted.org/packages/df/c5/4a57a86d12542c0f6e2744f262257b2aa5a3783098ec14e40f3e4b3a354a/ruff-0.11.6-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4dd6b09e98144ad7aec026f5588e493c65057d1b387dd937d7787baa531d9bc2", size = 10417121 },
- { url = "https://files.pythonhosted.org/packages/58/3f/a3b4346dff07ef5b862e2ba06d98fcbf71f66f04cf01d375e871382b5e4b/ruff-0.11.6-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:45b2e1d6c0eed89c248d024ea95074d0e09988d8e7b1dad8d3ab9a67017a5b03", size = 10073829 },
- { url = "https://files.pythonhosted.org/packages/93/cc/7ed02e0b86a649216b845b3ac66ed55d8aa86f5898c5f1691797f408fcb9/ruff-0.11.6-py3-none-musllinux_1_2_i686.whl", hash = "sha256:bd40de4115b2ec4850302f1a1d8067f42e70b4990b68838ccb9ccd9f110c5e8b", size = 11076108 },
- { url = "https://files.pythonhosted.org/packages/39/5e/5b09840fef0eff1a6fa1dea6296c07d09c17cb6fb94ed5593aa591b50460/ruff-0.11.6-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:77cda2dfbac1ab73aef5e514c4cbfc4ec1fbef4b84a44c736cc26f61b3814cd9", size = 11512366 },
- { url = "https://files.pythonhosted.org/packages/6f/4c/1cd5a84a412d3626335ae69f5f9de2bb554eea0faf46deb1f0cb48534042/ruff-0.11.6-py3-none-win32.whl", hash = "sha256:5151a871554be3036cd6e51d0ec6eef56334d74dfe1702de717a995ee3d5b287", size = 10485900 },
- { url = "https://files.pythonhosted.org/packages/42/46/8997872bc44d43df986491c18d4418f1caff03bc47b7f381261d62c23442/ruff-0.11.6-py3-none-win_amd64.whl", hash = "sha256:cce85721d09c51f3b782c331b0abd07e9d7d5f775840379c640606d3159cae0e", size = 11558592 },
- { url = "https://files.pythonhosted.org/packages/d7/6a/65fecd51a9ca19e1477c3879a7fda24f8904174d1275b419422ac00f6eee/ruff-0.11.6-py3-none-win_arm64.whl", hash = "sha256:3567ba0d07fb170b1b48d944715e3294b77f5b7679e8ba258199a250383ccb79", size = 10682766 },
-]
-
-[[package]]
-name = "scikit-learn"
-version = "1.6.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "joblib" },
- { name = "numpy" },
- { name = "scipy" },
- { name = "threadpoolctl" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/9e/a5/4ae3b3a0755f7b35a280ac90b28817d1f380318973cff14075ab41ef50d9/scikit_learn-1.6.1.tar.gz", hash = "sha256:b4fc2525eca2c69a59260f583c56a7557c6ccdf8deafdba6e060f94c1c59738e", size = 7068312 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/2e/3a/f4597eb41049110b21ebcbb0bcb43e4035017545daa5eedcfeb45c08b9c5/scikit_learn-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d056391530ccd1e501056160e3c9673b4da4805eb67eb2bdf4e983e1f9c9204e", size = 12067702 },
- { url = "https://files.pythonhosted.org/packages/37/19/0423e5e1fd1c6ec5be2352ba05a537a473c1677f8188b9306097d684b327/scikit_learn-1.6.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0c8d036eb937dbb568c6242fa598d551d88fb4399c0344d95c001980ec1c7d36", size = 11112765 },
- { url = "https://files.pythonhosted.org/packages/70/95/d5cb2297a835b0f5fc9a77042b0a2d029866379091ab8b3f52cc62277808/scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8634c4bd21a2a813e0a7e3900464e6d593162a29dd35d25bdf0103b3fce60ed5", size = 12643991 },
- { url = "https://files.pythonhosted.org/packages/b7/91/ab3c697188f224d658969f678be86b0968ccc52774c8ab4a86a07be13c25/scikit_learn-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:775da975a471c4f6f467725dff0ced5c7ac7bda5e9316b260225b48475279a1b", size = 13497182 },
- { url = "https://files.pythonhosted.org/packages/17/04/d5d556b6c88886c092cc989433b2bab62488e0f0dafe616a1d5c9cb0efb1/scikit_learn-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:8a600c31592bd7dab31e1c61b9bbd6dea1b3433e67d264d17ce1017dbdce8002", size = 11125517 },
-]
-
-[[package]]
-name = "scipy"
-version = "1.15.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/b7/b9/31ba9cd990e626574baf93fbc1ac61cf9ed54faafd04c479117517661637/scipy-1.15.2.tar.gz", hash = "sha256:cd58a314d92838f7e6f755c8a2167ead4f27e1fd5c1251fd54289569ef3495ec", size = 59417316 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/95/df/ef233fff6838fe6f7840d69b5ef9f20d2b5c912a8727b21ebf876cb15d54/scipy-1.15.2-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:a2ec871edaa863e8213ea5df811cd600734f6400b4af272e1c011e69401218e9", size = 38692502 },
- { url = "https://files.pythonhosted.org/packages/5c/20/acdd4efb8a68b842968f7bc5611b1aeb819794508771ad104de418701422/scipy-1.15.2-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:6f223753c6ea76983af380787611ae1291e3ceb23917393079dcc746ba60cfb5", size = 30085508 },
- { url = "https://files.pythonhosted.org/packages/42/55/39cf96ca7126f1e78ee72a6344ebdc6702fc47d037319ad93221063e6cf4/scipy-1.15.2-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:ecf797d2d798cf7c838c6d98321061eb3e72a74710e6c40540f0e8087e3b499e", size = 22359166 },
- { url = "https://files.pythonhosted.org/packages/51/48/708d26a4ab8a1441536bf2dfcad1df0ca14a69f010fba3ccbdfc02df7185/scipy-1.15.2-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:9b18aa747da280664642997e65aab1dd19d0c3d17068a04b3fe34e2559196cb9", size = 25112047 },
- { url = "https://files.pythonhosted.org/packages/dd/65/f9c5755b995ad892020381b8ae11f16d18616208e388621dfacc11df6de6/scipy-1.15.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:87994da02e73549dfecaed9e09a4f9d58a045a053865679aeb8d6d43747d4df3", size = 35536214 },
- { url = "https://files.pythonhosted.org/packages/de/3c/c96d904b9892beec978562f64d8cc43f9cca0842e65bd3cd1b7f7389b0ba/scipy-1.15.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69ea6e56d00977f355c0f84eba69877b6df084516c602d93a33812aa04d90a3d", size = 37646981 },
- { url = "https://files.pythonhosted.org/packages/3d/74/c2d8a24d18acdeae69ed02e132b9bc1bb67b7bee90feee1afe05a68f9d67/scipy-1.15.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:888307125ea0c4466287191e5606a2c910963405ce9671448ff9c81c53f85f58", size = 37230048 },
- { url = "https://files.pythonhosted.org/packages/42/19/0aa4ce80eca82d487987eff0bc754f014dec10d20de2f66754fa4ea70204/scipy-1.15.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:9412f5e408b397ff5641080ed1e798623dbe1ec0d78e72c9eca8992976fa65aa", size = 40010322 },
- { url = "https://files.pythonhosted.org/packages/d0/d2/f0683b7e992be44d1475cc144d1f1eeae63c73a14f862974b4db64af635e/scipy-1.15.2-cp310-cp310-win_amd64.whl", hash = "sha256:b5e025e903b4f166ea03b109bb241355b9c42c279ea694d8864d033727205e65", size = 41233385 },
-]
-
-[[package]]
-name = "shap"
-version = "0.47.2"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "cloudpickle" },
- { name = "numba" },
- { name = "numpy" },
- { name = "packaging" },
- { name = "pandas" },
- { name = "scikit-learn" },
- { name = "scipy" },
- { name = "slicer" },
- { name = "tqdm" },
- { name = "typing-extensions" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/65/d3ef8be3dc2ff6ca272d85d956bc4a511643af1b892658e088aefb3ac245/shap-0.47.2.tar.gz", hash = "sha256:8a53901fc44396d92a12b985d83ca4a47a7dcc4a04a7f7e556232a35f17d30c8", size = 2641500 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/a2/d2/529b941e7e343f9956c9787fe8527b9d8315ae140811f4a79833c633b249/shap-0.47.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:03d014351cb0ead1671ccb46cf38e7b3e810a19c3b6f79f4f84bb183b7c368bd", size = 553577 },
- { url = "https://files.pythonhosted.org/packages/e1/6c/c8f336325750159e5e81ae61653ae040c608b255de7960e40111a9992ed9/shap-0.47.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a177f1c996ac64690cc9bceed00bdb5ec406dfadc7bf87e5a79dea28607a768c", size = 546536 },
- { url = "https://files.pythonhosted.org/packages/a7/e3/7aeebfa2fe5a1316c3f2d29ec43df6cc361d1e844531e3bdd49997c5165f/shap-0.47.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f0179d33aaab65bf6089ab802575308da389c54392d9f3054c3d2d04d78ce3d0", size = 985371 },
- { url = "https://files.pythonhosted.org/packages/55/7d/6933982d51f638d03b16f3e3104816a9350a11cc8a616a5e76ef2fca7b89/shap-0.47.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:adbae48a3e844d81f8ad41c2d0cbf90bc82708e00238cc6b024690b84a5d5dcb", size = 992263 },
- { url = "https://files.pythonhosted.org/packages/51/f0/2634b76be6418b6f91b191e37cfafe24999d93b3d04fc6590be5cf3796d4/shap-0.47.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:6579a3ba14fdae6280041d4306e75899ff900b6f302e77f0b379e4504b19a735", size = 2007580 },
- { url = "https://files.pythonhosted.org/packages/ab/10/229ff676f626dc01a7607fcc5ce9435ddf6c41c6feda2e8f8e2a89200c62/shap-0.47.2-cp310-cp310-win_amd64.whl", hash = "sha256:4dd04511eebcb3c4407e5e4f2818d9be792434989f32c9cb25b1f1fa3ca3309b", size = 544244 },
-]
-
-[[package]]
-name = "six"
-version = "1.17.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
-]
-
-[[package]]
-name = "slicer"
-version = "0.0.8"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d3/f9/b4bce2825b39b57760b361e6131a3dacee3d8951c58cb97ad120abb90317/slicer-0.0.8.tar.gz", hash = "sha256:2e7553af73f0c0c2d355f4afcc3ecf97c6f2156fcf4593955c3f56cf6c4d6eb7", size = 14894 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/63/81/9ef641ff4e12cbcca30e54e72fb0951a2ba195d0cda0ba4100e532d929db/slicer-0.0.8-py3-none-any.whl", hash = "sha256:6c206258543aecd010d497dc2eca9d2805860a0b3758673903456b7df7934dc3", size = 15251 },
-]
-
-[[package]]
-name = "soupsieve"
-version = "2.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d7/ce/fbaeed4f9fb8b2daa961f90591662df6a86c1abf25c548329a86920aedfb/soupsieve-2.6.tar.gz", hash = "sha256:e2e68417777af359ec65daac1057404a3c8a5455bb8abc36f1a9866ab1a51abb", size = 101569 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d1/c2/fe97d779f3ef3b15f05c94a2f1e3d21732574ed441687474db9d342a7315/soupsieve-2.6-py3-none-any.whl", hash = "sha256:e72c4ff06e4fb6e4b5a9f0f55fe6e81514581fca1515028625d0f299c602ccc9", size = 36186 },
-]
-
-[[package]]
-name = "stack-data"
-version = "0.6.3"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "asttokens" },
- { name = "executing" },
- { name = "pure-eval" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/28/e3/55dcc2cfbc3ca9c29519eb6884dd1415ecb53b0e934862d3559ddcb7e20b/stack_data-0.6.3.tar.gz", hash = "sha256:836a778de4fec4dcd1dcd89ed8abff8a221f58308462e1c4aa2a3cf30148f0b9", size = 44707 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f1/7b/ce1eafaf1a76852e2ec9b22edecf1daa58175c090266e9f6c64afcd81d91/stack_data-0.6.3-py3-none-any.whl", hash = "sha256:d5558e0c25a4cb0853cddad3d77da9891a08cb85dd9f9f91b9f8cd66e511e695", size = 24521 },
-]
-
-[[package]]
-name = "threadpoolctl"
-version = "3.6.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 },
-]
-
-[[package]]
-name = "tinycss2"
-version = "1.4.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "webencodings" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7a/fd/7a5ee21fd08ff70d3d33a5781c255cbe779659bd03278feb98b19ee550f4/tinycss2-1.4.0.tar.gz", hash = "sha256:10c0972f6fc0fbee87c3edb76549357415e94548c1ae10ebccdea16fb404a9b7", size = 87085 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/e6/34/ebdc18bae6aa14fbee1a08b63c015c72b64868ff7dae68808ab500c492e2/tinycss2-1.4.0-py3-none-any.whl", hash = "sha256:3a49cf47b7675da0b15d0c6e1df8df4ebd96e9394bb905a5775adb0d884c5289", size = 26610 },
-]
-
-[[package]]
-name = "tomli"
-version = "2.2.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/18/87/302344fed471e44a87289cf4967697d07e532f2421fdaf868a303cbae4ff/tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff", size = 17175 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/6e/c2/61d3e0f47e2b74ef40a68b9e6ad5984f6241a942f7cd3bbfbdbd03861ea9/tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc", size = 14257 },
-]
-
-[[package]]
-name = "tomlkit"
-version = "0.13.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/b1/09/a439bec5888f00a54b8b9f05fa94d7f901d6735ef4e55dcec9bc37b5d8fa/tomlkit-0.13.2.tar.gz", hash = "sha256:fff5fe59a87295b278abd31bec92c15d9bc4a06885ab12bcea52c71119392e79", size = 192885 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f9/b6/a447b5e4ec71e13871be01ba81f5dfc9d0af7e473da256ff46bc0e24026f/tomlkit-0.13.2-py3-none-any.whl", hash = "sha256:7a974427f6e119197f670fbbbeae7bef749a6c14e793db934baefc1b5f03efde", size = 37955 },
-]
-
-[[package]]
-name = "tornado"
-version = "6.4.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/59/45/a0daf161f7d6f36c3ea5fc0c2de619746cc3dd4c76402e9db545bd920f63/tornado-6.4.2.tar.gz", hash = "sha256:92bad5b4746e9879fd7bf1eb21dce4e3fc5128d71601f80005afa39237ad620b", size = 501135 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/26/7e/71f604d8cea1b58f82ba3590290b66da1e72d840aeb37e0d5f7291bd30db/tornado-6.4.2-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:e828cce1123e9e44ae2a50a9de3055497ab1d0aeb440c5ac23064d9e44880da1", size = 436299 },
- { url = "https://files.pythonhosted.org/packages/96/44/87543a3b99016d0bf54fdaab30d24bf0af2e848f1d13d34a3a5380aabe16/tornado-6.4.2-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:072ce12ada169c5b00b7d92a99ba089447ccc993ea2143c9ede887e0937aa803", size = 434253 },
- { url = "https://files.pythonhosted.org/packages/cb/fb/fdf679b4ce51bcb7210801ef4f11fdac96e9885daa402861751353beea6e/tornado-6.4.2-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a017d239bd1bb0919f72af256a970624241f070496635784d9bf0db640d3fec", size = 437602 },
- { url = "https://files.pythonhosted.org/packages/4f/3b/e31aeffffc22b475a64dbeb273026a21b5b566f74dee48742817626c47dc/tornado-6.4.2-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c36e62ce8f63409301537222faffcef7dfc5284f27eec227389f2ad11b09d946", size = 436972 },
- { url = "https://files.pythonhosted.org/packages/22/55/b78a464de78051a30599ceb6983b01d8f732e6f69bf37b4ed07f642ac0fc/tornado-6.4.2-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bca9eb02196e789c9cb5c3c7c0f04fb447dc2adffd95265b2c7223a8a615ccbf", size = 437173 },
- { url = "https://files.pythonhosted.org/packages/79/5e/be4fb0d1684eb822c9a62fb18a3e44a06188f78aa466b2ad991d2ee31104/tornado-6.4.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:304463bd0772442ff4d0f5149c6f1c2135a1fae045adf070821c6cdc76980634", size = 437892 },
- { url = "https://files.pythonhosted.org/packages/f5/33/4f91fdd94ea36e1d796147003b490fe60a0215ac5737b6f9c65e160d4fe0/tornado-6.4.2-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:c82c46813ba483a385ab2a99caeaedf92585a1f90defb5693351fa7e4ea0bf73", size = 437334 },
- { url = "https://files.pythonhosted.org/packages/2b/ae/c1b22d4524b0e10da2f29a176fb2890386f7bd1f63aacf186444873a88a0/tornado-6.4.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:932d195ca9015956fa502c6b56af9eb06106140d844a335590c1ec7f5277d10c", size = 437261 },
- { url = "https://files.pythonhosted.org/packages/b5/25/36dbd49ab6d179bcfc4c6c093a51795a4f3bed380543a8242ac3517a1751/tornado-6.4.2-cp38-abi3-win32.whl", hash = "sha256:2876cef82e6c5978fde1e0d5b1f919d756968d5b4282418f3146b79b58556482", size = 438463 },
- { url = "https://files.pythonhosted.org/packages/61/cc/58b1adeb1bb46228442081e746fcdbc4540905c87e8add7c277540934edb/tornado-6.4.2-cp38-abi3-win_amd64.whl", hash = "sha256:908b71bf3ff37d81073356a5fadcc660eb10c1476ee6e2725588626ce7e5ca38", size = 438907 },
-]
-
-[[package]]
-name = "tqdm"
-version = "4.67.1"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "colorama", marker = "sys_platform == 'win32'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
-]
-
-[[package]]
-name = "traitlets"
-version = "5.14.3"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359 },
-]
-
-[[package]]
-name = "ty"
-version = "0.0.1a25"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/6b/e73bc3c1039ea72936158a08313155a49e5aa5e7db5205a149fe516a4660/ty-0.0.1a25.tar.gz", hash = "sha256:5550b24b9dd0e0f8b4b2c1f0fcc608a55d0421dd67b6c364bc7bf25762334511", size = 4403670 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8f/3b/4457231238a2eeb04cba4ba7cc33d735be68ee46ca40a98ae30e187de864/ty-0.0.1a25-py3-none-linux_armv6l.whl", hash = "sha256:d35b2c1f94a014a22875d2745aa0432761d2a9a8eb7212630d5caf547daeef6d", size = 8878803 },
- { url = "https://files.pythonhosted.org/packages/8a/fa/a328713dd310018fc7a381693d8588185baa2fdae913e01a6839187215df/ty-0.0.1a25-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:192edac94675a468bac7f6e04687a77a64698e4e1fe01f6a048bf9b6dde5b703", size = 8695667 },
- { url = "https://files.pythonhosted.org/packages/22/e8/5707939118992ced2bf5385adc3ede7723c1b717b07ad14c495eea1e47b4/ty-0.0.1a25-py3-none-macosx_11_0_arm64.whl", hash = "sha256:949523621f336e01bc7d687b7bd08fe838edadbdb6563c2c057ed1d264e820cf", size = 8159012 },
- { url = "https://files.pythonhosted.org/packages/eb/fb/ff313aa71602225cd78f1bce3017713d6d1b1c1e0fa8101ead4594a60d95/ty-0.0.1a25-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94f78f621458c05e59e890061021198197f29a7b51a33eda82bbb036e7ed73d7", size = 8433675 },
- { url = "https://files.pythonhosted.org/packages/c0/8d/cc7e7fb57215a15b575a43ed042bdd92971871e0decec1b26d2e7d969465/ty-0.0.1a25-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d9656fca8062a2c6709c30d76d662c96d2e7dbfee8f70e55ec6b6afd67b5d447", size = 8668456 },
- { url = "https://files.pythonhosted.org/packages/b8/6d/d7bf5909ed2dcdcbc1e2ca7eea80929893e2d188d9c36b3fcb2b36532ff6/ty-0.0.1a25-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9f3bbf523b49935bbd76e230408d858dce0d614f44f5807bbbd0954f64e0f01", size = 9023543 },
- { url = "https://files.pythonhosted.org/packages/b4/b8/72bcefb4be32e5a84f0b21de2552f16cdb4cae3eb271ac891c8199c26b1a/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:f13ea9815f4a54a0a303ca7bf411b0650e3c2a24fc6c7889ffba2c94f5e97a6a", size = 9700013 },
- { url = "https://files.pythonhosted.org/packages/90/0d/cf7e794b840cf6b0bbecb022e593c543f85abad27a582241cf2095048cb1/ty-0.0.1a25-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eab6e33ebe202a71a50c3d5a5580e3bc1a85cda3ffcdc48cec3f1c693b7a873b", size = 9372574 },
- { url = "https://files.pythonhosted.org/packages/1e/71/2d35e7d51b48eabd330e2f7b7e0bce541cbd95950c4d2f780e85f3366af1/ty-0.0.1a25-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6b9a31da43424cdab483703a54a561b93aabba84630788505329fc5294a9c62", size = 9535726 },
- { url = "https://files.pythonhosted.org/packages/57/d3/01ecc23bbd8f3e0dfbcf9172d06d84e88155c5f416f1491137e8066fd859/ty-0.0.1a25-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0a90d897a7c1a5ae9b41a4c7b0a42262a06361476ad88d783dbedd7913edadbc", size = 9003380 },
- { url = "https://files.pythonhosted.org/packages/de/f9/cde9380d8a1a6ca61baeb9aecb12cbec90d489aa929be55cd78ad5c2ccd9/ty-0.0.1a25-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:93c7e7ab2859af0f866d34d27f4ae70dd4fb95b847387f082de1197f9f34e068", size = 8401833 },
- { url = "https://files.pythonhosted.org/packages/0b/39/0acf3625b0c495011795a391016b572f97a812aca1d67f7a76621fdb9ebf/ty-0.0.1a25-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4a247061bd32bae3865a236d7f8b6c9916c80995db30ae1600999010f90623a9", size = 8706761 },
- { url = "https://files.pythonhosted.org/packages/25/73/7de1648f3563dd9d416d36ab5f1649bfd7b47a179135027f31d44b89a246/ty-0.0.1a25-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1711dd587eccf04fd50c494dc39babe38f4cb345bc3901bf1d8149cac570e979", size = 8792426 },
- { url = "https://files.pythonhosted.org/packages/7d/8a/b6e761a65eac7acd10b2e452f49b2d8ae0ea163ca36bb6b18b2dadae251b/ty-0.0.1a25-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5f4c9b0cf7995e2e3de9bab4d066063dea92019f2f62673b7574e3612643dd35", size = 9103991 },
- { url = "https://files.pythonhosted.org/packages/e4/25/9324ae947fcc4322470326cf8276a3fc2f08dc82adec1de79d963fdf7af5/ty-0.0.1a25-py3-none-win32.whl", hash = "sha256:168fc8aee396d617451acc44cd28baffa47359777342836060c27aa6f37e2445", size = 8387095 },
- { url = "https://files.pythonhosted.org/packages/3b/2b/cb12cbc7db1ba310aa7b1de9b4e018576f653105993736c086ee67d2ec02/ty-0.0.1a25-py3-none-win_amd64.whl", hash = "sha256:a2fad3d8e92bb4d57a8872a6f56b1aef54539d36f23ebb01abe88ac4338efafb", size = 9059225 },
- { url = "https://files.pythonhosted.org/packages/2f/c1/f6be8cdd0bf387c1d8ee9d14bb299b7b5d2c0532f550a6693216a32ec0c5/ty-0.0.1a25-py3-none-win_arm64.whl", hash = "sha256:dde2962d448ed87c48736e9a4bb13715a4cced705525e732b1c0dac1d4c66e3d", size = 8536832 },
-]
-
-[[package]]
-name = "types-pytz"
-version = "2025.2.0.20250326"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/4b/66/38c89861242f2c61c8315ddbcc7d7bbf64979f4b0bdc48db0ba62aeec330/types_pytz-2025.2.0.20250326.tar.gz", hash = "sha256:deda02de24f527066fc8d6a19e284ab3f3ae716a42b4adb6b40e75e408c08d36", size = 10595 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4e/e0/17f3a6670db5c95dc195f346e2e7290f22ba8327c188133959389b578cbd/types_pytz-2025.2.0.20250326-py3-none-any.whl", hash = "sha256:3c397fd1b845cd2b3adc9398607764ced9e578a98a5d1fbb4a9bc9253edfb162", size = 10222 },
-]
-
-[[package]]
-name = "typing-extensions"
-version = "4.13.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
-]
-
-[[package]]
-name = "tzdata"
-version = "2025.2"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839 },
-]
-
-[[package]]
-name = "virtualenv"
-version = "20.30.0"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "distlib" },
- { name = "filelock" },
- { name = "platformdirs" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/38/e0/633e369b91bbc664df47dcb5454b6c7cf441e8f5b9d0c250ce9f0546401e/virtualenv-20.30.0.tar.gz", hash = "sha256:800863162bcaa5450a6e4d721049730e7f2dae07720e0902b0e4040bd6f9ada8", size = 4346945 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/4c/ed/3cfeb48175f0671ec430ede81f628f9fb2b1084c9064ca67ebe8c0ed6a05/virtualenv-20.30.0-py3-none-any.whl", hash = "sha256:e34302959180fca3af42d1800df014b35019490b119eba981af27f2fa486e5d6", size = 4329461 },
-]
-
-[[package]]
-name = "wcwidth"
-version = "0.2.13"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6c/63/53559446a878410fc5a5974feb13d31d78d752eb18aeba59c7fef1af7598/wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5", size = 101301 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/fd/84/fd2ba7aafacbad3c4201d395674fc6348826569da3c0937e75505ead3528/wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", size = 34166 },
-]
-
-[[package]]
-name = "webencodings"
-version = "0.5.1"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0b/02/ae6ceac1baeda530866a85075641cec12989bd8d31af6d5ab4a3e8c92f47/webencodings-0.5.1.tar.gz", hash = "sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923", size = 9721 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/f4/24/2a3e3df732393fed8b3ebf2ec078f05546de641fe1b667ee316ec1dcf3b7/webencodings-0.5.1-py2.py3-none-any.whl", hash = "sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78", size = 11774 },
-]
-
-[[package]]
-name = "xbooster"
-source = { editable = "." }
-dependencies = [
- { name = "catboost" },
- { name = "lightgbm" },
- { name = "matplotlib" },
- { name = "numpy" },
- { name = "pandas" },
- { name = "pyarrow" },
- { name = "scikit-learn" },
- { name = "scipy" },
- { name = "shap" },
- { name = "xgboost" },
-]
-
-[package.dev-dependencies]
-dev = [
- { name = "faker" },
- { name = "ipykernel" },
- { name = "nbconvert" },
- { name = "pandas-stubs" },
- { name = "pre-commit" },
- { name = "prek" },
- { name = "pylint" },
- { name = "pytest" },
- { name = "ruff" },
- { name = "ty" },
-]
-
-[package.metadata]
-requires-dist = [
- { name = "catboost", specifier = ">=1.2.7,<2.0.0" },
- { name = "lightgbm", specifier = ">=4.0.0,<5.0.0" },
- { name = "matplotlib", specifier = ">=3.8.0,<4.0.0" },
- { name = "numpy", specifier = ">=1.19.5,<2.0.0" },
- { name = "pandas", specifier = ">=2.2.2,<3.0.0" },
- { name = "pyarrow", specifier = ">=19.0.1,<20.0.0" },
- { name = "scikit-learn", specifier = ">=1.3.0,<2.0.0" },
- { name = "scipy", specifier = ">=1.11.4,<2.0.0" },
- { name = "shap", specifier = ">=0.44.0,<1.0.0" },
- { name = "xgboost", specifier = ">=2.0.0,<4.0.0" },
-]
-
-[package.metadata.requires-dev]
-dev = [
- { name = "faker", specifier = ">=37.0.2" },
- { name = "ipykernel", specifier = ">=6.29.5" },
- { name = "nbconvert", specifier = ">=7.16.6" },
- { name = "pandas-stubs", specifier = ">=2.2.3" },
- { name = "pre-commit", specifier = ">=4.0.1,<5.0.0" },
- { name = "prek", specifier = ">=0.2.0" },
- { name = "pylint", specifier = ">=3.2.6,<4.0.0" },
- { name = "pytest", specifier = ">=8.3.2,<9.0.0" },
- { name = "ruff", specifier = ">=0.11.2" },
- { name = "ty", specifier = ">=0.0.1a21" },
-]
-
-[[package]]
-name = "xgboost"
-version = "3.0.5"
-source = { registry = "https://pypi.org/simple" }
-dependencies = [
- { name = "numpy" },
- { name = "nvidia-nccl-cu12", marker = "platform_machine != 'aarch64' and sys_platform == 'linux'" },
- { name = "scipy" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/7d/c0/561b88dabe82f45555dd2b68abd5d79f787d3e383436a6a54453d5deeb3f/xgboost-3.0.5.tar.gz", hash = "sha256:1a57a0d64a06b596992b664fe17dd1f9782138c6e35ad8fb6355e19a359fa50f", size = 1159729 }
-wheels = [
- { url = "https://files.pythonhosted.org/packages/ea/df/bf2416bfc138b1d2fa261ecb7f612ca00545aae126aeb1dc314df3291d73/xgboost-3.0.5-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:f974028c7a0c3ae51ef87e2900405436ddafff46452f6f334f5cc295e921429b", size = 2249197 },
- { url = "https://files.pythonhosted.org/packages/28/c0/2c3e8a141180171aae913825bf5a200f37d1f7c598268cd8b6a219fb0eab/xgboost-3.0.5-py3-none-macosx_12_0_arm64.whl", hash = "sha256:40c324c329bf74f44571cdafb7aa7435366309d76ec14b3e16874862aeb14351", size = 2025998 },
- { url = "https://files.pythonhosted.org/packages/66/68/e0e8285282ba81858d74d699cfee9e562a2a3cc7975f0fbd068b83aac559/xgboost-3.0.5-py3-none-manylinux2014_aarch64.whl", hash = "sha256:c580c82500ad566d927581381550561300492c0bc9c143b2e5be208d16f093d1", size = 4841604 },
- { url = "https://files.pythonhosted.org/packages/69/32/eb7e862179194c6440eab63f834a3de064d6340a8b873b5520ac035891db/xgboost-3.0.5-py3-none-manylinux2014_x86_64.whl", hash = "sha256:d7f57a04629b52bae91a80e6721b9cdd009b605827a9eca67953675292b4487e", size = 4906211 },
- { url = "https://files.pythonhosted.org/packages/3b/d5/6c60111482f41fd680eb0e81e016498bea313cc00a6a4a41b38c8b45bd5c/xgboost-3.0.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d0fe44aaca76e9c4598d3be98ae94661aa53e4c4ceb161dc7183258f0e6fc138", size = 4602832 },
- { url = "https://files.pythonhosted.org/packages/64/ad/61a86228e981b15361ff963e84648b1a29ab43debd95f7c2b3ef9d94dca1/xgboost-3.0.5-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:a03210a3e54c9e543f480db9636fee57247cfcd1ae850b353aeac59eea5ca350", size = 94872848 },
- { url = "https://files.pythonhosted.org/packages/00/5a/f43bad68b31269a72bdd66102732ea4473e98f421ee9f71379e35dcb56f5/xgboost-3.0.5-py3-none-win_amd64.whl", hash = "sha256:660774249d28a729ba8d22dd3d2c048c56e58f65a683b25ef3252e3383fe956f", size = 56826727 },
-]
diff --git a/xbooster/__init__.py b/xbooster/__init__.py
index ebead66..e8e38d5 100644
--- a/xbooster/__init__.py
+++ b/xbooster/__init__.py
@@ -5,12 +5,21 @@
from gradient boosted tree models (XGBoost and CatBoost).
"""
-__version__ = "0.2.7"
+from . import finetuner as finetuner
+from . import shap_scorecard as shap_scorecard
+
+__version__ = "0.2.8"
__author__ = "xRiskLab"
__email__ = "contact@xrisklab.ai"
+# Create alias for cleaner import path
+shap = shap_scorecard
+
__all__ = [
"__version__",
"__author__",
"__email__",
+ "shap",
+ "shap_scorecard",
+ "finetuner",
]
diff --git a/xbooster/_parser.py b/xbooster/_parser.py
index 2053ed0..64e1f4e 100644
--- a/xbooster/_parser.py
+++ b/xbooster/_parser.py
@@ -133,7 +133,7 @@ def recurse_backwards(self, first_node, splits) -> str:
Returns:
The constructed condition string.
"""
- query_list = []
+ query_list: list[str] = []
def _recurse(x):
"""
diff --git a/xbooster/_utils.py b/xbooster/_utils.py
index a2df290..b97ed64 100644
--- a/xbooster/_utils.py
+++ b/xbooster/_utils.py
@@ -86,7 +86,7 @@ def generate_interaction_constraints(self, features):
"""
Generate interaction constraints based on the features of the dataset.
"""
- interaction_constraints = {}
+ interaction_constraints: dict[str, list[str]] = {}
for feature in features:
base_feature = feature.rsplit("_", 1)[0]
if base_feature not in interaction_constraints:
@@ -333,8 +333,8 @@ def __init__(self, max_categories=None, top_p=0.9, other_token="__other__"):
self.max_categories = max_categories
self.top_p = top_p
self.other_token = other_token
- self.category_maps = {}
- self.cat_features_ = None
+ self.category_maps: dict[str, set[str]] = {}
+ self.cat_features_: list[str] = []
def fit(self, X: pd.DataFrame, y=None, cat_features: Optional[list[str]] = None):
"""Fit the preprocessor to the DataFrame."""
@@ -385,11 +385,11 @@ class CatBoostTreeVisualizer:
def __init__(self, scorecard: pd.DataFrame, plot_config: Optional[Dict[str, Any]] = None):
self.scorecard = scorecard
- self.tree_cache = {}
+ self.tree_cache: dict[int, dict[str, Any]] = {}
self.plot_config = plot_config or {}
# Default configuration
- self.config = {
+ self.config: dict[str, Any] = {
"facecolor": "#ffffff",
"edgecolor": "black",
"edgewidth": 0,
diff --git a/xbooster/catboost_scorecard.py b/xbooster/catboost_scorecard.py
index ab3cdcd..999e674 100644
--- a/xbooster/catboost_scorecard.py
+++ b/xbooster/catboost_scorecard.py
@@ -1,6 +1,6 @@
"""
-CatBoost Scorecard Script
-=================================
+catboost_scorecard.py
+
This module provides functionality to extract data for scorecards from CatBoost models.
It handles both numerical and categorical features with one-hot encoding approach.
@@ -8,7 +8,7 @@
Github: @deburky
License: MIT
This code is licensed under the MIT License.
-Copyright (c) 2025 Denis Burakov
+Copyright (c) 2025 xRiskLab
"""
@@ -31,7 +31,10 @@ class CatBoostScorecard:
"""
# Class variable to store debug info
- debug_info = {}
+ debug_info: dict[str, Any] = {}
+ level_conditions: dict[int, list[str]] = {}
+ model: Any = None
+ pool: Any = None
@staticmethod
def _is_numeric_only_condition(condition: str) -> bool:
@@ -51,7 +54,7 @@ def _extract_feature_names(pool: Pool) -> List[str]:
if hasattr(pool, "get_feature_names") and callable(pool.get_feature_names):
feature_names = pool.get_feature_names()
# Alternative approach
- elif hasattr(pool, "_feature_names"):
+ elif hasattr(pool, "_feature_names") and pool._feature_names is not None:
feature_names = pool._feature_names
return feature_names
@@ -119,7 +122,7 @@ def _parse_condition(
return f"{feature} {'>' if is_true else '<='}"
@staticmethod
- def _get_leaf_conditions(cb_obj: object, pool: Pool, tree_idx: int) -> Dict[int, str]:
+ def _get_leaf_conditions(cb_obj: Any, pool: Pool, tree_idx: int) -> Dict[int, str]:
"""Get leaf conditions for a given tree index, handling both types of trees."""
split_conditions = cb_obj._get_tree_splits(tree_idx, pool)
leaf_count = int(cb_obj._get_tree_leaf_counts()[tree_idx])
@@ -204,7 +207,7 @@ def trees_to_scorecard(
leaf_count = int(cb_obj._get_tree_leaf_counts()[tree_idx])
for leaf_idx in range(leaf_count):
- val_str = leaf_vals[leaf_idx] if leaf_idx < len(leaf_vals) else "val = 0.0"
+ val_str = str(leaf_vals[leaf_idx]) if leaf_idx < len(leaf_vals) else "val = 0.0"
clean_val = float(val_str.replace("val = ", "").strip())
conditions = leaf_conditions.get(leaf_idx, "")
@@ -355,8 +358,8 @@ def extract_leaf_weights(model: CatBoostClassifier) -> pd.DataFrame:
records = []
for tree_idx in range(tree_count):
leaf_vals = cb_obj._get_tree_leaf_values(tree_idx)
- for node_idx, val_str in enumerate(leaf_vals):
- clean_val = float(val_str.replace("val = ", "").strip())
+ for node_idx, val_raw in enumerate(leaf_vals):
+ clean_val = float(str(val_raw).replace("val = ", "").strip())
records.append(
{
"Tree": tree_idx,
diff --git a/xbooster/catboost_wrapper.py b/xbooster/catboost_wrapper.py
index 33d5209..2ad04c5 100644
--- a/xbooster/catboost_wrapper.py
+++ b/xbooster/catboost_wrapper.py
@@ -2,6 +2,12 @@
catboost_wrapper.py
This module implements the inference functionality for CatBoost models.
+
+Author: Denis Burakov
+Github: @deburky
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
"""
import re
@@ -108,7 +114,7 @@ def generate_feature_mappings(
Returns:
Dictionary of feature mappings
"""
- feature_mappings = defaultdict(
+ feature_mappings: Any = defaultdict(
lambda: defaultdict(lambda: {"value": [], "weight": [], "trees": []})
)
@@ -139,8 +145,8 @@ def _aggregate_feature_mappings(self) -> None:
"""Aggregate values across trees for each feature condition."""
for _, conditions in self.feature_mappings.items():
for _, details in conditions.items():
- weights = details["weight"]
- values = details["value"]
+ weights: Any = details["weight"]
+ values: Any = details["value"]
total_weight = sum(weights)
if total_weight > 0:
weighted_avg = sum(v * w for v, w in zip(values, weights)) / total_weight
@@ -148,7 +154,8 @@ def _aggregate_feature_mappings(self) -> None:
weighted_avg = sum(values) / len(values) if values else 0.0
details["agg_value"] = weighted_avg
details["total_weight"] = total_weight
- details["tree_count"] = len(set(details["trees"]))
+ trees: Any = details["trees"]
+ details["tree_count"] = len(set(trees))
def calculate_feature_importance(self) -> Dict[str, float]:
"""
@@ -519,7 +526,7 @@ def predict_score(
# Set the appropriate value column based on method
original_value_column = self.value_column
original_use_woe = self.use_woe
- temp_scorecard = None
+ temp_scorecard: Any = None
try:
if method == "raw":
@@ -530,13 +537,14 @@ def predict_score(
self.use_woe = True
elif method == "pdo":
# Use the enhanced scorecard with Points column
- if hasattr(self, "enhanced_scorecard"):
+ if hasattr(self, "enhanced_scorecard") and self.enhanced_scorecard is not None:
temp_scorecard = self.scorecard
self.scorecard = self.enhanced_scorecard
self.value_column = "Points"
self.use_woe = False
# Make prediction using the appropriate method
+ scores: Any
if isinstance(features, pd.DataFrame):
scores = self._predict_score_batch(features)
else:
@@ -572,10 +580,11 @@ def _predict_score_batch(self, df: pd.DataFrame, method: str = "raw") -> np.ndar
if not isinstance(conditions, str) or not np.any(unassigned):
continue
- # Only process unassigned rows
- subset = df.loc[unassigned_indices[unassigned]]
- if len(subset) == 0:
+ # Only process unassigned rows (use iloc for position-based indexing)
+ unassigned_positions = unassigned_indices[unassigned]
+ if len(unassigned_positions) == 0:
continue
+ subset = df.iloc[unassigned_positions]
# Apply conditions directly to subset using pandas filtering
matches = np.ones(len(subset), dtype=bool)
@@ -589,6 +598,7 @@ def _predict_score_batch(self, df: pd.DataFrame, method: str = "raw") -> np.ndar
# Apply appropriate filter based on condition type
try:
+ value: Any
if " = " in condition:
value = condition.split(" = ")[1].strip("'\"")
condition_matches = subset[feature].astype(str) == value
@@ -613,12 +623,11 @@ def _predict_score_batch(self, df: pd.DataFrame, method: str = "raw") -> np.ndar
if not valid_filter:
continue
- # Get indices of matching rows in the original array
+ # Get positions of matching rows in the original array
if np.any(matches):
- matching_subset_indices = subset.index[matches]
- matching_positions = np.where(
- np.isin(unassigned_indices, matching_subset_indices)
- )[0]
+ # Since we used iloc, matches correspond to positions in unassigned_positions
+ matching_positions_in_subset = np.where(matches)[0]
+ matching_positions = unassigned_positions[matching_positions_in_subset]
# Add the leaf value to matching rows
value = leaf[value_col]
@@ -641,14 +650,14 @@ def _predict_score_batch(self, df: pd.DataFrame, method: str = "raw") -> np.ndar
else tree_data.index[0]
)
default_leaf = tree_data.loc[default_idx]
- value = default_leaf[value_col]
+ default_value: Any = default_leaf[value_col]
if method == "raw":
# No normalization for raw method
pass
elif method == "woe" and len(self.tree_indices) > 0:
# For WOE, normalize by tree count
- value = value / len(self.tree_indices)
- scores[unassigned_indices[unassigned]] += value
+ default_value = default_value / len(self.tree_indices)
+ scores[unassigned_indices[unassigned]] += default_value
return scores
@@ -688,24 +697,25 @@ def _predict_score_single(self, features: Dict[str, Any], method: str = "raw") -
# Evaluate condition based on type
try:
+ cond_value: Any
if " = " in condition:
- value = condition.split(" = ")[1].strip("'\"")
- if str(feature_value) != value:
+ cond_value = condition.split(" = ")[1].strip("'\"")
+ if str(feature_value) != cond_value:
conditions_met = False
break
elif " != " in condition:
- value = condition.split(" != ")[1].strip("'\"")
- if str(feature_value) == value:
+ cond_value = condition.split(" != ")[1].strip("'\"")
+ if str(feature_value) == cond_value:
conditions_met = False
break
elif " <= " in condition:
- value = float(condition.split(" <= ")[1])
- if float(feature_value) > value:
+ cond_value = float(condition.split(" <= ")[1])
+ if float(feature_value) > cond_value:
conditions_met = False
break
elif " > " in condition:
- value = float(condition.split(" > ")[1])
- if float(feature_value) <= value:
+ cond_value = float(condition.split(" > ")[1])
+ if float(feature_value) <= cond_value:
conditions_met = False
break
except (ValueError, TypeError):
@@ -714,14 +724,14 @@ def _predict_score_single(self, features: Dict[str, Any], method: str = "raw") -
# If all conditions met, add this leaf's value to the score
if conditions_met:
- value = leaf[value_col]
+ leaf_value: Any = leaf[value_col]
if method == "raw":
# No normalization for raw method
pass
elif method == "woe" and len(self.tree_indices) > 0:
# For WOE, normalize by tree count
- value = value / len(self.tree_indices)
- score += value
+ leaf_value = leaf_value / len(self.tree_indices)
+ score += leaf_value
assigned = True
break
@@ -732,13 +742,13 @@ def _predict_score_single(self, features: Dict[str, Any], method: str = "raw") -
if "Count" in tree_data.columns
else tree_data.index[0]
)
- value = tree_data.loc[default_idx][value_col]
+ default_value: Any = tree_data.loc[default_idx][value_col]
if method == "raw":
# No normalization for raw method
pass
elif method == "woe" and len(self.tree_indices) > 0:
# For WOE, normalize by tree count
- value = value / len(self.tree_indices)
- score += value
+ default_value = default_value / len(self.tree_indices)
+ score += default_value
return score
diff --git a/xbooster/cb_constructor.py b/xbooster/cb_constructor.py
index 3a522de..2795c98 100644
--- a/xbooster/cb_constructor.py
+++ b/xbooster/cb_constructor.py
@@ -1,6 +1,6 @@
"""
-CatBoost Scorecard Constructor
-=================================
+cb_constructor.py
+
This module provides a high-level interface for working with CatBoost scorecards.
It combines the functionality of CatBoostScorecard and CatBoostWOEMapper to provide
a streamlined workflow for creating and using scorecards.
@@ -9,9 +9,10 @@
Github: @deburky
License: MIT
This code is licensed under the MIT License.
-Copyright (c) 2025 Denis Burakov
+Copyright (c) 2025 xRiskLab
"""
+import contextlib
from typing import Any, Dict, Optional, Union
import numpy as np
@@ -20,9 +21,10 @@
from xbooster.catboost_scorecard import CatBoostScorecard
from xbooster.catboost_wrapper import CatBoostWOEMapper
+from xbooster.shap_scorecard import compute_shap_scores, extract_shap_values_cb
-class CatBoostScorecardConstructor:
+class CBScorecardConstructor:
"""
A high-level interface for working with CatBoost scorecards.
This class combines the functionality of CatBoostScorecard and CatBoostWOEMapper
@@ -32,23 +34,74 @@ class CatBoostScorecardConstructor:
def __init__(
self,
model: Optional[CatBoostClassifier] = None,
- pool: Optional[Pool] = None,
+ pool: Optional[Union[Pool, pd.DataFrame]] = None,
+ y: Optional[pd.Series] = None,
use_woe: bool = False,
points_column: Optional[str] = None,
+ n_base_trees: Optional[int] = None,
) -> None:
"""
Initialize the scorecard constructor.
Args:
model: Trained CatBoostClassifier
- pool: CatBoost Pool object used for training/validation
+ pool: CatBoost Pool object OR DataFrame (X) for training/validation.
+ If DataFrame is provided, y must also be provided to create Pool automatically.
+ y: Labels (required if pool is a DataFrame)
use_woe: If True, use WOE values; if False, use XAddEvidence (default: False)
points_column: If provided, use this column for scoring
+ n_base_trees: Number of base trees (for fine-tuned models). If set,
+ a TreeSource column is added to the scorecard.
+
+ Examples:
+ # Using Pool object (original API)
+ constructor = CBScorecardConstructor(model, pool)
+
+ # Using X, y (consistent with XGBoost/LightGBM API)
+ constructor = CBScorecardConstructor(model, X_train, y_train)
"""
self.model = model
- self.pool = pool
self.use_woe = use_woe
self.points_column = points_column
+ self.pool: Any = None
+ self.X: Any = None
+ self.y: Any = None
+
+ # Fine-tuning awareness
+ if n_base_trees is not None and model is not None:
+ total_trees = model.tree_count_
+ if n_base_trees > total_trees:
+ raise ValueError(
+ f"n_base_trees ({n_base_trees}) exceeds total trees ({total_trees})"
+ )
+ self.n_base_trees = n_base_trees
+
+ # Support both Pool object and (X, y) pattern for consistency with XGBoost/LightGBM
+ if isinstance(pool, pd.DataFrame) and y is not None:
+ # Create Pool from X and y (consistent with XGBoost/LightGBM API)
+ # Extract categorical features from model if available
+ cat_features = None
+ if self.model is not None:
+ with contextlib.suppress(AttributeError, RuntimeError):
+ if cat_feature_indices := self.model.get_cat_feature_indices():
+ cat_features = cat_feature_indices
+ self.pool = Pool(pool, y, cat_features=cat_features)
+ self.X = pool # Store for get_leafs() method
+ self.y = y
+ elif isinstance(pool, Pool):
+ # Original API: Pool object provided
+ self.pool = pool
+ self.X = None # Will be extracted from pool if needed
+ self.y = None
+ else:
+ # No pool provided (lazy initialization)
+ self.pool = pool
+ self.X = None
+ self.y = None
+
+ # Auto-build scorecard if both model and pool are provided
+ if self.model is not None and self.pool is not None:
+ self._build_scorecard()
self.scorecard_df: Optional[pd.DataFrame] = None
self.mapper: Optional[CatBoostWOEMapper] = None
self.scorecard: Optional[pd.DataFrame] = None
@@ -68,6 +121,53 @@ def __init__(
self.original_scorecard: Optional[pd.DataFrame] = None
self.points_enabled = False
+ @classmethod
+ def from_finetune_result(cls, result, X, y, **kwargs):
+ """Create constructor from a FineTuneResult.
+
+ Args:
+ result: FineTuneResult from finetune_cb().
+ X: Training/fine-tuning features (pd.DataFrame).
+ y: Training/fine-tuning labels (pd.Series).
+ **kwargs: Additional arguments passed to CBScorecardConstructor.
+
+ Returns:
+ CBScorecardConstructor with n_base_trees set.
+ """
+ return cls(result.model, X, y, n_base_trees=result.n_base_trees, **kwargs)
+
+ def summarize_score_sources(self) -> pd.DataFrame:
+ """Summarize IV contribution split between base and fine-tuned trees.
+
+ Returns:
+ DataFrame with columns: Feature, BaseIV, FinetunedIV, TotalIV
+
+ Raises:
+ ValueError: If n_base_trees is not set or scorecard not constructed.
+ """
+ if self.n_base_trees is None:
+ raise ValueError(
+ "n_base_trees is not set. Use n_base_trees parameter or from_finetune_result()."
+ )
+
+ scorecard = self.construct_scorecard()
+ if "TreeSource" not in scorecard.columns:
+ raise ValueError(
+ "TreeSource column not found. Reconstruct scorecard with n_base_trees set."
+ )
+
+ grouped = (
+ scorecard.groupby(["TreeSource", "Feature"])["IV"].sum().unstack(level=0, fill_value=0)
+ )
+ result = pd.DataFrame(
+ {
+ "BaseIV": grouped.get("base", 0),
+ "FinetunedIV": grouped.get("finetuned", 0),
+ }
+ )
+ result["TotalIV"] = result["BaseIV"] + result["FinetunedIV"]
+ return result.reset_index().rename(columns={"index": "Feature"})
+
def fit(self, model: CatBoostClassifier, pool: Pool) -> None:
"""
Fit the scorecard constructor with a trained model and pool.
@@ -129,6 +229,7 @@ def construct_scorecard(self) -> pd.DataFrame:
"""
if self.scorecard_df is None:
self._build_scorecard()
+ assert self.scorecard_df is not None
scorecard = self.scorecard_df.copy()
@@ -143,24 +244,24 @@ def construct_scorecard(self) -> pd.DataFrame:
# Extract feature name and split value
if " <= " in last_condition:
feature, value = last_condition.split(" <= ")
- scorecard.loc[idx, "Feature"] = feature.strip()
- scorecard.loc[idx, "Split"] = float(value.strip())
- scorecard.loc[idx, "Sign"] = "<="
+ scorecard.at[idx, "Feature"] = feature.strip()
+ scorecard.at[idx, "Split"] = float(value.strip())
+ scorecard.at[idx, "Sign"] = "<="
elif " > " in last_condition:
feature, value = last_condition.split(" > ")
- scorecard.loc[idx, "Feature"] = feature.strip()
- scorecard.loc[idx, "Split"] = float(value.strip())
- scorecard.loc[idx, "Sign"] = ">"
+ scorecard.at[idx, "Feature"] = feature.strip()
+ scorecard.at[idx, "Split"] = float(value.strip())
+ scorecard.at[idx, "Sign"] = ">"
elif " = " in last_condition:
feature, value = last_condition.split(" = ")
- scorecard.loc[idx, "Feature"] = feature.strip()
- scorecard.loc[idx, "Split"] = value.strip().strip("'\"")
- scorecard.loc[idx, "Sign"] = "="
+ scorecard.at[idx, "Feature"] = feature.strip()
+ scorecard.at[idx, "Split"] = value.strip().strip("'\"")
+ scorecard.at[idx, "Sign"] = "="
elif " != " in last_condition:
feature, value = last_condition.split(" != ")
- scorecard.loc[idx, "Feature"] = feature.strip()
- scorecard.loc[idx, "Split"] = value.strip().strip("'\"")
- scorecard.loc[idx, "Sign"] = "!="
+ scorecard.at[idx, "Feature"] = feature.strip()
+ scorecard.at[idx, "Split"] = value.strip().strip("'\"")
+ scorecard.at[idx, "Sign"] = "!="
# Calculate average event rate
total_events = scorecard["Events"].sum()
@@ -169,34 +270,44 @@ def construct_scorecard(self) -> pd.DataFrame:
clipped_event_rate = scorecard["EventRate"].clip(lower=1e-3, upper=1 - 1e-3)
# Calculate WOE and IV
- scorecard["WOE"] = np.log(
+ woe_raw = np.log(
(clipped_event_rate / (1 - clipped_event_rate))
/ (avg_event_rate / (1 - avg_event_rate))
- ).fillna(0)
+ )
+ scorecard["WOE"] = pd.Series(woe_raw).fillna(0).values
scorecard["IV"] = (scorecard["EventRate"] - avg_event_rate) * scorecard["WOE"]
# Calculate CountPct
scorecard["CountPct"] = (scorecard["Count"] / total_count).fillna(0.0)
- # Return only the basic columns
- return scorecard[
- [
- "Tree",
- "LeafIndex",
- "Feature",
- "Sign",
- "Split",
- "CountPct",
- "Count",
- "NonEvents",
- "Events",
- "EventRate",
- "XAddEvidence",
- "WOE",
- "IV",
- "DetailedSplit",
- ]
+ # Add TreeSource column for fine-tuned models
+ if self.n_base_trees is not None:
+ scorecard["TreeSource"] = np.where(
+ scorecard["Tree"] < self.n_base_trees, "base", "finetuned"
+ )
+
+ # Build column list
+ base_columns = [
+ "Tree",
+ "LeafIndex",
+ "Feature",
+ "Sign",
+ "Split",
+ "CountPct",
+ "Count",
+ "NonEvents",
+ "Events",
+ "EventRate",
+ "XAddEvidence",
+ "WOE",
+ "IV",
+ "DetailedSplit",
]
+ if self.n_base_trees is not None:
+ base_columns.append("TreeSource")
+
+ # Return only the basic columns
+ return scorecard[base_columns]
def get_scorecard(self) -> pd.DataFrame:
"""
@@ -209,6 +320,46 @@ def get_scorecard(self) -> pd.DataFrame:
raise ValueError("Scorecard not built yet. Call fit() first.")
return self.scorecard_df
+ def get_leafs(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ output_type: str = "leaf_index",
+ ) -> pd.DataFrame:
+ """
+ Get leaf indices for a new dataset.
+
+ Args:
+ X: Input features DataFrame
+ output_type: 'leaf_index' (only supported type for CatBoost)
+
+ Returns:
+ DataFrame with columns [tree_0, tree_1, ..., tree_n] containing leaf indices
+
+ Note:
+ CatBoost uses calc_leaf_indexes() which returns integer leaf indices.
+ The 'margin' output_type is not supported for CatBoost.
+ """
+ if self.model is None:
+ raise ValueError("Model must be set before calling get_leafs()")
+
+ if output_type != "leaf_index":
+ raise ValueError(f"CatBoost only supports output_type='leaf_index'. Got: {output_type}")
+
+ # Create Pool from X (no labels needed for leaf prediction)
+ # Extract categorical features from model if available
+ cat_features = None
+ with contextlib.suppress(AttributeError, RuntimeError):
+ if cat_feature_indices := self.model.get_cat_feature_indices():
+ cat_features = cat_feature_indices
+ pool = Pool(X, cat_features=cat_features)
+ leaf_indices = self.model.calc_leaf_indexes(pool)
+
+ n_trees = leaf_indices.shape[1]
+ _colnames = [f"tree_{i}" for i in range(n_trees)]
+
+ # Return as integer DataFrame (matching XGBoost/LightGBM behavior)
+ return pd.DataFrame(leaf_indices, columns=_colnames).astype(int)
+
def get_feature_importance(self) -> Dict[str, float]:
"""
Get feature importance scores.
@@ -221,21 +372,48 @@ def get_feature_importance(self) -> Dict[str, float]:
return self.mapper.feature_importance
def predict_score(
- self, features: Union[pd.DataFrame, Dict[str, Any]], method: str = "raw"
- ) -> Union[float, np.ndarray]:
+ self,
+ features: Union[pd.DataFrame, Dict[str, Any]],
+ method: Optional[str] = None,
+ pdo: float = 50,
+ target_points: float = 600,
+ target_odds: float = 19,
+ ) -> Any:
"""
Predict scores using the specified method.
Args:
features: DataFrame or dictionary of feature values
method: Scoring method to use:
- - 'raw': Use original XAddEvidence
- - 'woe': Use Weight of Evidence values
- - 'pdo': Use points-based scoring
+ - None (default): Use traditional points-based scoring (scorecard-based)
+ - 'raw': Use original XAddEvidence (scorecard-based)
+ - 'woe': Use Weight of Evidence values (scorecard-based)
+ - 'shap': Use SHAP values directly (computes SHAP on-the-fly, no binning table)
+ pdo: Points to Double the Odds (only used for method='shap')
+ target_points: Target score for reference odds (only used for method='shap')
+ target_odds: Reference odds ratio (only used for method='shap')
Returns:
Series containing predicted scores
"""
+ # Convert dict to DataFrame if needed
+ if isinstance(features, dict):
+ features = pd.DataFrame([features])
+
+ # Handle SHAP method separately (no binning table needed)
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ if self.pdo_params is not None:
+ pdo = self.pdo_params.get("pdo", pdo)
+ target_points = self.pdo_params.get("target_points", target_points)
+ target_odds = self.pdo_params.get("target_odds", target_odds)
+ return self._predict_score_shap(features, pdo, target_points, target_odds)
+
+ # Default to traditional points-based scoring if method is None
+ if method is None:
+ method = "pdo"
+
+ # For other methods, use existing scorecard-based approach
if self.mapper is None:
raise ValueError("Mapper not initialized. Call fit() first.")
@@ -249,27 +427,96 @@ def predict_score(
if method == "raw" or method == "woe":
# Use original mapper for raw and woe methods if available
- original_mapper = self.original_mapper if self.points_enabled else self.mapper
+ active_mapper = self.original_mapper if self.points_enabled else self.mapper
+ assert active_mapper is not None
# Set the appropriate value column based on method
if method == "raw":
- original_mapper.value_column = "XAddEvidence"
- original_mapper.use_woe = False
+ active_mapper.value_column = "XAddEvidence"
+ active_mapper.use_woe = False
else:
- original_mapper.value_column = "WOE"
- original_mapper.use_woe = True
+ active_mapper.value_column = "WOE"
+ active_mapper.use_woe = True
- scores = original_mapper.predict_score(features)
+ scores = active_mapper.predict_score(features)
elif method == "pdo":
# Use current mapper for points method
self.mapper.value_column = "Points"
self.mapper.use_woe = False
scores = self.mapper.predict_score(features)
else:
- raise ValueError(f"Unknown method: {method}")
+ raise ValueError(
+ f"Unknown method: {method}. Use None (default), 'raw', 'woe', or 'shap'."
+ )
return pd.Series(scores) if isinstance(scores, (float, np.ndarray)) else scores
+ def _predict_score_shap(
+ self,
+ features: pd.DataFrame,
+ pdo: float = 50,
+ target_points: float = 600,
+ target_odds: float = 19,
+ ) -> pd.Series:
+ """
+ Predict scores using SHAP values directly (no binning table).
+
+ This method computes SHAP values on-the-fly for input features and scales them
+ using PDO formula. Different from scorecard-based methods which use pre-computed
+ binned values.
+
+ Args:
+ features: DataFrame of feature values
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ Series of predicted scores
+ """
+ if self.model is None:
+ raise ValueError("Model not set. Call fit() first.")
+
+ # Create Pool for CatBoost
+ # For prediction, we don't need labels, but Pool requires them
+ # Create dummy labels (will be ignored for SHAP computation)
+ dummy_labels = np.zeros(len(features))
+ pool = Pool(features, dummy_labels)
+
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_cb(
+ self.model, pool
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Validate feature count matches
+ if shap_values.shape[1] != len(features.columns):
+ raise ValueError(
+ f"Feature count mismatch: SHAP values have {shap_values.shape[1]} features, "
+ f"but input has {len(features.columns)} columns"
+ )
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ # Compute SHAP-based scores using the dedicated function
+ scorecard_df = compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=features.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
+ # Extract final scores
+ scores = scorecard_df["score"]
+
+ return pd.Series(scores, name="Score")
+
def transform(self, features: Union[pd.DataFrame, Dict[str, Any]]) -> pd.DataFrame:
"""
Transform features using the scorecard mapping.
@@ -352,6 +599,7 @@ def create_points(
target_points: float = 600,
target_odds: float = 19,
precision_points: int = 0,
+ score_type: str = "WOE",
) -> pd.DataFrame:
"""
Create points for the scorecard using PDO (Points to Double the Odds) scaling.
@@ -373,11 +621,11 @@ def create_points(
# First get the base scorecard
scorecard = self.construct_scorecard().copy()
- # Always use WOE for points calculation to ensure sufficient variance
- value_col = "WOE"
-
- # Base score from average event rate if available
+ # Select value column based on score_type
+ value_col = "XAddEvidence" if score_type == "XAddEvidence" else "WOE"
+ # Get base value based on score_type
if "EventRate" in scorecard.columns:
+ # For WOE/XAddEvidence, use average event rate
base_odds = scorecard["EventRate"].mean() / (1 - scorecard["EventRate"].mean())
else:
base_odds = target_odds # fallback
@@ -386,11 +634,10 @@ def create_points(
factor = pdo / np.log(2)
offset = target_points - factor * np.log(base_odds)
- # Raw contribution score from WOE or XAddEvidence
- scorecard["RawScore"] = -factor * scorecard[value_col]
-
+ # Raw contribution score from selected value column
n_trees = len(scorecard["Tree"].unique())
- scorecard["RawScore"] = -factor * scorecard[value_col]
+ # Don't negate here - match XGBoost approach: negate in formula instead
+ scorecard["RawScore"] = factor * scorecard[value_col]
scorecard["RawScore"] /= n_trees # Normalize by number of trees
# Align maximum score within each tree
@@ -399,8 +646,11 @@ def create_points(
mean_shift = (tree_max.sum() - offset) / len(tree_max)
# Calculate points using apply
+ # Match XGBoost formula: -ScaledScore + var_offsets - shft_base_pts
+ # So: -RawScore + tree_max - mean_shift
+ # This ensures higher XAddEvidence (higher risk) results in lower scores
scorecard["Points"] = scorecard.apply(
- lambda row: tree_max[row.name] - row["RawScore"] - mean_shift, axis=1
+ lambda row: -row["RawScore"] + tree_max[row.name] - mean_shift, axis=1
)
scorecard.reset_index(inplace=True)
@@ -429,23 +679,44 @@ def create_points(
return scorecard
- def predict_scores(self, features: Union[pd.DataFrame, Dict[str, Any]]) -> pd.DataFrame:
+ def predict_scores(
+ self,
+ features: Union[pd.DataFrame, Dict[str, Any]],
+ method: Optional[str] = None,
+ pdo: float = 50,
+ target_points: float = 600,
+ target_odds: float = 19,
+ ) -> pd.DataFrame:
"""
- Predict scores for each tree and total score.
+ Predict decomposed scores for a given dataset.
Args:
features: Input features as DataFrame or dictionary
+ method: Scoring method to use:
+ - None (default): Use traditional scorecard-based approach (tree-level decomposition)
+ - 'shap': Use SHAP values directly (feature-level decomposition)
+ pdo: Points to Double the Odds (only used for method='shap')
+ target_points: Target score for reference odds (only used for method='shap')
+ target_odds: Reference odds ratio (only used for method='shap')
Returns:
- DataFrame with columns for each tree's score and total score
+ DataFrame with decomposed scores (tree-level for default, feature-level for SHAP)
"""
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ if self.pdo_params is not None:
+ pdo = self.pdo_params.get("pdo", pdo)
+ target_points = self.pdo_params.get("target_points", target_points)
+ target_odds = self.pdo_params.get("target_odds", target_odds)
+ return self._predict_scores_shap(features, pdo, target_points, target_odds)
+
+ # Default: use traditional scorecard-based approach (tree-level decomposition)
if self.mapper is None:
raise ValueError("Mapper not initialized. Call fit() first.")
# Get scores for each tree
tree_scores = {}
for tree_idx in self.tree_indices:
- # tree_data = self.scorecard[self.scorecard["Tree"] == tree_idx] # Not used
if isinstance(features, dict):
features_df = pd.DataFrame([features])
else:
@@ -459,6 +730,62 @@ def predict_scores(self, features: Union[pd.DataFrame, Dict[str, Any]]) -> pd.Da
scores_df["Score"] = scores_df.sum(axis=1)
return scores_df
+ def _predict_scores_shap(
+ self,
+ features: Union[pd.DataFrame, Dict[str, Any]],
+ pdo: float = 50,
+ target_points: float = 600,
+ target_odds: float = 19,
+ ) -> pd.DataFrame:
+ """
+ Predict decomposed scores using SHAP values (feature-level decomposition).
+
+ Uses intercept-based scoring where intercept and offset are distributed
+ evenly across features, ensuring feature scores sum to the total score.
+
+ Args:
+ features: Input features DataFrame or dictionary
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ DataFrame with feature-level score contributions and total score
+ """
+ if self.model is None:
+ raise ValueError("Model not set. Call fit() first.")
+
+ # Convert dict to DataFrame if needed
+ if isinstance(features, dict):
+ features_df = pd.DataFrame([features])
+ else:
+ features_df = features.copy()
+
+ # Create Pool for CatBoost
+ dummy_labels = np.zeros(len(features_df))
+ pool = Pool(features_df, dummy_labels)
+
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_cb(
+ self.model, pool
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ return compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=features_df.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
def generate_sql_query(self):
"""
Generate an SQL query for deploying the scorecard.
diff --git a/xbooster/constructor.py b/xbooster/constructor.py
index 175a16a..73450b7 100644
--- a/xbooster/constructor.py
+++ b/xbooster/constructor.py
@@ -1,15 +1,26 @@
"""
+constructor.py
+
Unified interface for importing scorecard constructors.
This module provides access to both XGBoost and CatBoost scorecard constructors.
+
+Authors: Denis Burakov
+Github: @deburky
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
"""
from typing import Any, Dict, Optional, Protocol, Union
import pandas as pd
-from xbooster.cb_constructor import CatBoostScorecardConstructor
+from xbooster.cb_constructor import CBScorecardConstructor
from xbooster.xgb_constructor import XGBScorecardConstructor
+# Backward compatibility alias
+CatBoostScorecardConstructor = CBScorecardConstructor
+
class ScorecardConstructor(Protocol):
"""Protocol defining the interface for scorecard constructors."""
@@ -30,4 +41,4 @@ def sql_query(self) -> str: ...
def generate_sql_query(self, table_name: str = "my_table") -> str: ...
-__all__ = ["XGBScorecardConstructor", "CatBoostScorecardConstructor"]
+__all__ = ["XGBScorecardConstructor", "CBScorecardConstructor", "CatBoostScorecardConstructor"]
diff --git a/xbooster/explainer.py b/xbooster/explainer.py
index e508ef6..2fe9742 100644
--- a/xbooster/explainer.py
+++ b/xbooster/explainer.py
@@ -3,48 +3,11 @@
This module provides utilities for interpretability of XGBoost models built for scoring purposes.
-Functions:
- - build_interactions_splits(scorecard_constructor):
- Build interactions splits dataframe from the xgb_scorecard_with_splits.
- In this we perform aggregation of features for each split by assigning the same gain
- to all features used in the split. For `max_depth > 1`, each split is a combination
- of features and for `max_depth = 1`, each split is a single feature. This means
- that what one sees in the final leaf node is not the only feature used for a split.
-
- - plot_importance(scorecard_constructor=None, metric="Likelihood", **kwargs):
- Calculates and plots the importance of features based on the XGBoost scorecard.
- The 'Likelihood' metric is used as the default metric, while other metrics,
- such as 'Points', 'NegLogLikelihood', 'IV', can be used as well.
-
- - plot_local_importance(scorecard_constructor, X: pd.DataFrame, **kwargs):
- Plot local importance based on the provided scorecard constructor and a sample,
- which needs to be explained.
-
- - plot_tree(scorecard_constructor, num_trees=0, **kwargs):
- Plot tree visualization for the XGBoost model and show the metrics of interest.
- TODOs: Available options are to be documented.
-
- - plot_catboost_importance(scorecard_constructor: Optional[CatBoostScorecardConstructor] = None,
- metric: str = "XAddEvidence",
- normalize: bool = True,
- max_features: int = 20,
- fontfamily: Optional[str] = "Monospace",
- fontsize: Optional[int] = 12,
- dpi: Optional[int] = 100,
- title: Optional[str] = "Feature importance",
- **kwargs: Any) -> None:
- Plot feature importance for CatBoost scorecard.
-
- Args:
- scorecard_constructor: CatBoostScorecardConstructor instance
- metric: Metric to use for importance ('XAddEvidence', 'WOE', 'IV')
- normalize: Whether to normalize the importance values
- max_features: Maximum number of features to display (default: 20)
- fontfamily: Font family for the plot
- fontsize: Font size for the plot
- dpi: DPI for the plot
- title: Title for the plot
- **kwargs: Additional arguments to pass to matplotlib
+Authors: Denis Burakov
+Github: @deburky
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
"""
import re
@@ -57,15 +20,18 @@
from matplotlib.ticker import MultipleLocator
from ._utils import calculate_information_value, calculate_likelihood, calculate_odds
-from .cb_constructor import CatBoostScorecardConstructor
+from .cb_constructor import CBScorecardConstructor
from .xgb_constructor import XGBScorecardConstructor
+# Backward compatibility alias
+CatBoostScorecardConstructor = CBScorecardConstructor
+
def extract_splits_info(features: str) -> List[Dict[str, Union[str, float]]]:
"""Extracts split information from the DetailedSplit feature."""
splits_info = []
features = re.sub(r"\s*or missing\s*,?\s*", ", ", features) # NOTE: Missing values
- feature_names = sorted(set(re.findall(r"\b([^\d\W]+)\b", features)))
+ feature_names = sorted(set[Any](re.findall(r"\b([^\d\W]+)\b", features)))
for feature in feature_names:
regex = re.compile(rf"\b{feature}\b\s*(?P[<>=]+)\s*(?P[^,]+)")
if match := regex.search(features):
@@ -101,7 +67,7 @@ def build_interactions_splits( # pylint: disable=R0914
Returns:
pd.DataFrame: A dataframe with interactions splits.
"""
- interactions_data = []
+ interactions_data: list[dict[str, Any]] = []
if dataframe is None and scorecard_constructor is None:
raise ValueError("Either 'scorecard_constructor' or 'dataframe' must be provided.")
@@ -129,7 +95,7 @@ def build_interactions_splits( # pylint: disable=R0914
# Find sign and value that corresponds to each feature in the split
splits_info = []
features = re.sub(r"\s*or missing\s*,?\s*", ", ", features) # NOTE: Missing values
- feature_names = sorted(set(re.findall(r"\b([^\d\W]+)\b", features)))
+ feature_names = sorted(set[Any](re.findall(r"\b([^\d\W]+)\b", features)))
for feature in feature_names:
regex = re.compile(rf"\b{feature}\b\s*(?P[<>=]+)\s*(?P[^,]+)")
@@ -196,7 +162,7 @@ def split_and_count( # pylint: disable=R0914
ValueError: If dataframe is not provided.
ValueError: If label_column is not provided.
"""
- split_and_count_data = []
+ split_and_count_data: Any = []
dataframe = (
pd.concat([scorecard_constructor.X, scorecard_constructor.y], axis=1) # type: ignore
@@ -266,7 +232,7 @@ def split_and_count( # pylint: disable=R0914
# pylint: disable=too-many-arguments, too-many-lines
def plot_importance(
- scorecard_constructor: Optional[XGBScorecardConstructor] = None,
+ scorecard_constructor: Optional[Any] = None,
metric: str = "Likelihood",
normalize: bool = True,
method: Optional[str] = None,
@@ -303,7 +269,7 @@ def plot_importance(
raise ValueError("scorecard_constructor must be provided.")
# Check if CatBoost constructor is provided
- if isinstance(scorecard_constructor, CatBoostScorecardConstructor):
+ if isinstance(scorecard_constructor, CBScorecardConstructor):
raise NotImplementedError(
"Plotting feature importance for CatBoost models is not implemented yet. "
"Please use the constructor's built-in plotting method: constructor.plot_feature_importance()"
@@ -422,7 +388,7 @@ def plot_score_distribution(
y_true = scorecard_constructor.y
y_pred = scorecard_constructor.predict_score(scorecard_constructor.X) # type: ignore
- # type: ignore
+ assert y_true is not None and y_pred is not None
if isinstance(y_true, pd.DataFrame) and y_true.shape[1].shape is None:
raise ValueError("Must have two classes.")
@@ -647,10 +613,10 @@ def __init__(
precision: Optional[int] = None,
):
self.scorecard_constructor: Optional[XGBScorecardConstructor] = None
- self.tree_dump: Optional[Dict[str, Any]] = None
+ self.tree_dump: Any = None
self.scorecard_frame: Optional[pd.DataFrame] = None
self.metrics: List[str] = metrics if metrics is not None else []
- self.precision: int = precision
+ self.precision: Optional[int] = precision
# pylint: disable=too-many-locals, too-many-statements
def parse_xgb_output(
@@ -670,16 +636,16 @@ def parse_xgb_output(
"""
self.scorecard_constructor = scorecard_constructor
- nodes = {}
+ nodes: Dict[str, Dict[str, Any]] = {}
root_id = None
if self.scorecard_constructor is None:
raise ValueError("The scorecard constructor is not set.")
- self.tree_dump = self.scorecard_constructor.model.get_booster().get_dump()[num_trees]
- self.scorecard_frame = self.scorecard_constructor.xgb_scorecard_with_points.query(
- f"Tree == {num_trees}"
- )
+ self.tree_dump = str(self.scorecard_constructor.model.get_booster().get_dump()[num_trees])
+ scorecard_with_points = self.scorecard_constructor.xgb_scorecard_with_points
+ assert scorecard_with_points is not None
+ self.scorecard_frame = scorecard_with_points.query(f"Tree == {num_trees}")
for line in self.tree_dump.split("\n"):
line = line.strip()
@@ -711,22 +677,19 @@ def parse_xgb_output(
conditions,
)
- node_dict = {
+ children: Dict[str, Any] = {}
+ for branch, target in branches:
+ if target != "" and branch != "missing":
+ children[branch] = target.strip()
+
+ node_dict: Dict[str, Any] = {
"name": feature,
"depth": depth,
- "children": {},
+ "children": children,
}
- for branch, target in branches:
- if target != "" and branch != "missing":
- node_dict["children"][branch] = target.strip()
-
nodes[node_id] = node_dict
- for branch, target in branches:
- if target != "" and branch != "missing":
- node_dict["children"][branch] = target.strip()
-
nodes[node_id] = node_dict
def _build_tree(node_id: str, current_depth: int) -> Dict[str, Any]:
@@ -767,7 +730,7 @@ def align_format(node: Dict[str, Any]) -> Dict[str, Any]:
"depth": node["depth"],
}
if "children" in node:
- aligned_children = {}
+ aligned_children: dict[str, Any] = {}
for branch, child_node in node["children"].items():
if isinstance(child_node, str):
aligned_children[branch] = child_node
@@ -776,6 +739,7 @@ def align_format(node: Dict[str, Any]) -> Dict[str, Any]:
aligned_node["children"] = aligned_children
return aligned_node
+ assert root_id is not None
aligned_tree = align_format(_build_tree(root_id, 0))
self.tree_dump = {"0": aligned_tree}
@@ -792,7 +756,7 @@ def _get_node_metrics(self, node_id: str) -> Optional[Dict[str, float]]:
Raises:
ValueError: If any of the required metrics are not found in the scorecard dataframe.
"""
- if self.metrics is None:
+ if self.metrics is None or self.scorecard_frame is None:
return None
if missing_metrics := [
metric for metric in self.metrics if metric not in self.scorecard_frame.columns
@@ -1028,7 +992,7 @@ def plot_tree(
def plot_catboost_importance(
- scorecard_constructor: Optional[CatBoostScorecardConstructor] = None,
+ scorecard_constructor: Optional[CBScorecardConstructor] = None,
metric: str = "XAddEvidence",
normalize: bool = True,
max_features: int = 20,
@@ -1042,7 +1006,7 @@ def plot_catboost_importance(
Plot feature importance for CatBoost scorecard.
Args:
- scorecard_constructor: CatBoostScorecardConstructor instance
+ scorecard_constructor: CBScorecardConstructor instance
metric: Metric to use for importance ('XAddEvidence', 'WOE', 'IV')
normalize: Whether to normalize the importance values
max_features: Maximum number of features to display (default: 20)
diff --git a/xbooster/finetuner.py b/xbooster/finetuner.py
new file mode 100644
index 0000000..9bdc5ce
--- /dev/null
+++ b/xbooster/finetuner.py
@@ -0,0 +1,348 @@
+"""
+finetuner.py
+
+Lightweight wrappers around XGBoost, LightGBM, and CatBoost continued training APIs.
+These helpers freeze base trees and append new trees during fine-tuning, returning
+a FineTuneResult with metadata about base vs. fine-tuned trees.
+
+When new features are added (expanded features), base model predictions are used as
+initial scores (warm-start) since native continued training APIs require matching
+feature sets. In this case n_base_trees=0 as no base trees are carried over.
+
+Authors: Denis Burakov
+Github: @deburky
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
+"""
+
+from dataclasses import dataclass, field
+from typing import Any, Optional
+
+
+@dataclass
+class FineTuneResult:
+ """Result of a fine-tuning operation.
+
+ Attributes:
+ model: The fine-tuned model.
+ n_base_trees: Number of trees from the base model preserved in the
+ fine-tuned model. Zero when expanded features are used (warm-start).
+ n_total_trees: Total number of trees after fine-tuning.
+ base_features: Feature names used by the original model.
+ all_features: All feature names used by the fine-tuned model.
+ new_features: Features added during fine-tuning.
+ """
+
+ model: Any
+ n_base_trees: int
+ n_total_trees: int
+ base_features: list = field(default_factory=list)
+ all_features: list = field(default_factory=list)
+ new_features: list = field(default_factory=list)
+
+
+def finetune_xgb(
+ base_model,
+ X,
+ y,
+ n_estimators: int = 50,
+ learning_rate: Optional[float] = None,
+ **kwargs: Any,
+) -> FineTuneResult:
+ """Fine-tune an XGBoost model with continued training.
+
+ For same features: base trees are frozen and new trees are appended via
+ xgb_model= parameter. For expanded features: base model predictions are
+ used as initial scores (warm-start) since XGBoost requires matching features.
+
+ Args:
+ base_model: Trained xgboost.XGBClassifier.
+ X: Fine-tuning features (pd.DataFrame). May include new columns.
+ y: Fine-tuning labels (pd.Series).
+ n_estimators: Number of new trees to add.
+ learning_rate: Override learning rate (None keeps base model's rate).
+ **kwargs: Additional parameters passed to XGBClassifier constructor.
+
+ Returns:
+ FineTuneResult with the fine-tuned model and tree metadata.
+ """
+ import xgboost as xgb
+
+ # Get base tree count and features
+ booster = base_model.get_booster()
+ n_base_trees = booster.num_boosted_rounds()
+ base_features = list(booster.feature_names or X.columns.tolist())
+
+ # Determine all features: base features first, then new ones
+ new_features = [f for f in X.columns if f not in base_features]
+ all_features = base_features + new_features
+
+ # Ensure column order: base features first for correct index mapping
+ X_ordered = X[all_features]
+
+ # Clone base model params and override
+ params = base_model.get_params()
+ params["n_estimators"] = n_estimators
+ if learning_rate is not None:
+ params["learning_rate"] = learning_rate
+ params.update(kwargs)
+
+ new_model = xgb.XGBClassifier(**params)
+
+ if new_features:
+ # Expanded features: warm-start with base model predictions as base_margin
+ base_preds = base_model.predict(X[base_features], output_margin=True)
+ new_model.fit(X_ordered, y, base_margin=base_preds)
+ n_base_trees_out = 0
+ else:
+ # Same features: native continued training (base trees frozen)
+ new_model.fit(X_ordered, y, xgb_model=base_model.get_booster())
+ n_base_trees_out = n_base_trees
+
+ n_total_trees = new_model.get_booster().num_boosted_rounds()
+
+ return FineTuneResult(
+ model=new_model,
+ n_base_trees=n_base_trees_out,
+ n_total_trees=n_total_trees,
+ base_features=base_features,
+ all_features=all_features,
+ new_features=new_features,
+ )
+
+
+def finetune_lgb(
+ base_model,
+ X,
+ y,
+ n_estimators: int = 50,
+ learning_rate: Optional[float] = None,
+ **kwargs: Any,
+) -> FineTuneResult:
+ """Fine-tune a LightGBM model with continued training.
+
+ For same features: base trees are frozen and new trees are appended via
+ init_model= parameter. For expanded features: base model predictions are
+ used as initial scores (warm-start).
+
+ Args:
+ base_model: Trained lightgbm.LGBMClassifier.
+ X: Fine-tuning features (pd.DataFrame). May include new columns.
+ y: Fine-tuning labels (pd.Series).
+ n_estimators: Number of new trees to add.
+ learning_rate: Override learning rate (None keeps base model's rate).
+ **kwargs: Additional parameters passed to LGBMClassifier constructor.
+
+ Returns:
+ FineTuneResult with the fine-tuned model and tree metadata.
+ """
+ from lightgbm import LGBMClassifier
+
+ # Get base tree count and features
+ n_base_trees = base_model.booster_.num_trees()
+ base_features = list(base_model.booster_.feature_name())
+
+ # Determine all features: base features first, then new ones
+ new_features = [f for f in X.columns if f not in base_features]
+ all_features = base_features + new_features
+
+ # Ensure column order: base features first
+ X_ordered = X[all_features]
+
+ # Clone base model params and override
+ params = base_model.get_params()
+ params["n_estimators"] = n_estimators
+ if learning_rate is not None:
+ params["learning_rate"] = learning_rate
+ params.update(kwargs)
+
+ new_model = LGBMClassifier(**params)
+
+ if new_features:
+ # Expanded features: warm-start with base model predictions as init_score
+ base_preds = base_model.predict(X[base_features], raw_score=True)
+ new_model.fit(X_ordered, y, init_score=base_preds)
+ n_base_trees_out = 0
+ else:
+ # Same features: native continued training (base trees frozen)
+ new_model.fit(X_ordered, y, init_model=base_model)
+ n_base_trees_out = n_base_trees
+
+ n_total_trees = new_model.booster_.num_trees()
+
+ return FineTuneResult(
+ model=new_model,
+ n_base_trees=n_base_trees_out,
+ n_total_trees=n_total_trees,
+ base_features=base_features,
+ all_features=all_features,
+ new_features=new_features,
+ )
+
+
+# Known CatBoost constructor params (subset that's safe to pass)
+_CB_SAFE_PARAMS = {
+ "iterations",
+ "learning_rate",
+ "depth",
+ "l2_leaf_reg",
+ "model_size_reg",
+ "rsm",
+ "loss_function",
+ "border_count",
+ "feature_border_type",
+ "per_float_feature_quantization",
+ "input_borders",
+ "output_borders",
+ "fold_permutation_block",
+ "od_pval",
+ "od_wait",
+ "od_type",
+ "nan_mode",
+ "counter_calc_method",
+ "leaf_estimation_iterations",
+ "leaf_estimation_method",
+ "thread_count",
+ "random_seed",
+ "use_best_model",
+ "best_model_min_trees",
+ "verbose",
+ "silent",
+ "logging_level",
+ "metric_period",
+ "ctr_leaf_count_limit",
+ "store_all_simple_ctr",
+ "max_ctr_complexity",
+ "has_time",
+ "allow_const_label",
+ "target_border",
+ "classes_count",
+ "class_weights",
+ "auto_class_weights",
+ "class_names",
+ "one_hot_max_size",
+ "random_strength",
+ "name",
+ "ignored_features",
+ "train_dir",
+ "custom_loss",
+ "custom_metric",
+ "eval_metric",
+ "bagging_temperature",
+ "save_snapshot",
+ "snapshot_file",
+ "snapshot_interval",
+ "fold_len_multiplier",
+ "used_ram_limit",
+ "gpu_ram_part",
+ "pinned_memory_size",
+ "allow_writing_files",
+ "final_ctr_computation_mode",
+ "approx_on_full_history",
+ "boosting_type",
+ "simple_ctr",
+ "combinations_ctr",
+ "per_feature_ctr",
+ "ctr_target_border_count",
+ "task_type",
+ "devices",
+ "bootstrap_type",
+ "subsample",
+ "sampling_frequency",
+ "sampling_unit",
+ "mvs_reg",
+ "grow_policy",
+ "min_data_in_leaf",
+ "max_leaves",
+ "score_function",
+ "leaf_estimation_backtracking",
+ "langevin",
+ "diffusion_temperature",
+ "posterior_sampling",
+ "boost_from_average",
+ "text_features",
+ "tokenizers",
+ "dictionaries",
+ "feature_calcers",
+ "text_processing",
+ "embedding_features",
+ "callback",
+ "eval_fraction",
+}
+
+
+def finetune_cb(
+ base_model,
+ X,
+ y,
+ n_estimators: int = 50,
+ learning_rate: Optional[float] = None,
+ cat_features: Optional[list] = None,
+ **kwargs: Any,
+) -> FineTuneResult:
+ """Fine-tune a CatBoost model with continued training.
+
+ For same features: base trees are frozen and new trees are appended via
+ init_model= parameter. For expanded features: base model predictions are
+ used as baseline scores (warm-start).
+
+ Args:
+ base_model: Trained catboost.CatBoostClassifier.
+ X: Fine-tuning features (pd.DataFrame). May include new columns.
+ y: Fine-tuning labels (pd.Series).
+ n_estimators: Number of new trees to add.
+ learning_rate: Override learning rate (None keeps base model's rate).
+ cat_features: List of categorical feature names or indices.
+ **kwargs: Additional parameters passed to CatBoostClassifier constructor.
+
+ Returns:
+ FineTuneResult with the fine-tuned model and tree metadata.
+ """
+ from catboost import CatBoostClassifier, Pool
+
+ # Get base tree count and features
+ n_base_trees = base_model.tree_count_
+ base_features = list(base_model.feature_names_)
+
+ # Determine all features: base features first, then new ones
+ new_features = [f for f in X.columns if f not in base_features]
+ all_features = base_features + new_features
+
+ # Ensure column order: base features first
+ X_ordered = X[all_features]
+
+ # Extract safe params from base model (filter internal-only params)
+ all_params = base_model.get_all_params()
+ params = {k: v for k, v in all_params.items() if k in _CB_SAFE_PARAMS}
+ params["iterations"] = n_estimators
+ if learning_rate is not None:
+ params["learning_rate"] = learning_rate
+ params.update(kwargs)
+
+ new_model = CatBoostClassifier(**params)
+
+ if new_features:
+ # Expanded features: warm-start with base model predictions as baseline
+ base_pool = Pool(X[base_features], cat_features=cat_features)
+ base_preds = base_model.predict(base_pool, prediction_type="RawFormulaVal")
+ # Reshape to (n_samples, 1) as required by CatBoost baseline
+ baseline = base_preds.reshape(-1, 1) if base_preds.ndim == 1 else base_preds
+ ft_pool = Pool(X_ordered, y, cat_features=cat_features, baseline=baseline)
+ new_model.fit(ft_pool)
+ n_base_trees_out = 0
+ else:
+ # Same features: native continued training (base trees frozen)
+ new_model.fit(X_ordered, y, init_model=base_model, cat_features=cat_features)
+ n_base_trees_out = n_base_trees
+
+ n_total_trees = new_model.tree_count_
+
+ return FineTuneResult(
+ model=new_model,
+ n_base_trees=n_base_trees_out,
+ n_total_trees=n_total_trees,
+ base_features=base_features,
+ all_features=all_features,
+ new_features=new_features,
+ )
diff --git a/xbooster/lgb_constructor.py b/xbooster/lgb_constructor.py
index e79d0b5..4083c5e 100644
--- a/xbooster/lgb_constructor.py
+++ b/xbooster/lgb_constructor.py
@@ -12,32 +12,22 @@
scorecard, creating points, and predicting scores based on the constructed
scorecard.
-Example usage (to be implemented):
-
- import pandas as pd
- from sklearn.metrics import roc_auc_score
- import lightgbm as lgb
-
- # Instantiate the LGBScorecardConstructor
- scorecard_constructor = LGBScorecardConstructor(
- lgb_model, X.loc[ix_train], y.loc[ix_train]
- )
- # Generate a scorecard
- scorecard_constructor.construct_scorecard()
- lgb_scorecard_with_points = scorecard_constructor.create_points(
- pdo=50, target_points=600, target_odds=50
- )
- # Make predictions using the scorecard
- credit_scores = scorecard_constructor.predict_score(X.loc[ix_test])
- gini = roc_auc_score(y.loc[ix_test], -credit_scores) * 2 - 1
- print(f"Test Gini score: {gini:.2%}")
+Authors: Denis Burakov, Sangjun Moon
+Github: @deburky, @RektPunk
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
+
"""
+from typing import Optional
+
import numpy as np
import pandas as pd
from lightgbm import LGBMClassifier
from ._utils import calculate_information_value, calculate_weight_of_evidence
+from .shap_scorecard import compute_shap_scores, extract_shap_values_lgb
# Note: These will be needed when implementing the methods:
# from typing import Optional
@@ -82,7 +72,7 @@ class LGBScorecardConstructor: # pylint: disable=R0902
model.predict(X, raw_score=True) returns margins
"""
- def __init__(self, model, X, y): # pylint: disable=C0103
+ def __init__(self, model, X, y, n_base_trees=None, base_score=None): # pylint: disable=C0103
"""
Initialize the LGBScorecardConstructor.
@@ -90,6 +80,11 @@ def __init__(self, model, X, y): # pylint: disable=C0103
model: Trained LightGBM classifier
X: Training features
y: Training labels
+ n_base_trees: Number of base trees (for fine-tuned models). If set,
+ a TreeSource column is added to the scorecard.
+ base_score: Override the base score. Useful for fine-tuned models where
+ the fine-tuning data has a different event rate than the original
+ training data.
"""
if not isinstance(model, LGBMClassifier):
raise TypeError("model must be an instance of lightgbm.LGBMClassifier")
@@ -114,7 +109,19 @@ def __init__(self, model, X, y): # pylint: disable=C0103
# - We subtract base_score from Tree 0 to normalize all trees to the same scale
# - Then add logit(base_score) during scaling to distribute it across all trees
# - This ensures each tree contributes proportionally to the final scorecard
- self.base_score = np.log(y.mean() / (1 - y.mean()))
+ if base_score is not None:
+ self.base_score = base_score
+ else:
+ self.base_score = np.log(y.mean() / (1 - y.mean()))
+
+ # Fine-tuning awareness
+ if n_base_trees is not None:
+ total_trees = self.booster_.num_trees()
+ if n_base_trees > total_trees:
+ raise ValueError(
+ f"n_base_trees ({n_base_trees}) exceeds total trees ({total_trees})"
+ )
+ self.n_base_trees = n_base_trees
# Initialize scorecard storage
self.lgb_scorecard = None
@@ -126,6 +133,21 @@ def __init__(self, model, X, y): # pylint: disable=C0103
self.score_type = None
self._sql_query = None
+ @classmethod
+ def from_finetune_result(cls, result, X, y, base_score=None):
+ """Create constructor from a FineTuneResult.
+
+ Args:
+ result: FineTuneResult from finetune_lgb().
+ X: Training/fine-tuning features.
+ y: Training/fine-tuning labels.
+ base_score: Override base score (optional).
+
+ Returns:
+ LGBScorecardConstructor with n_base_trees set.
+ """
+ return cls(result.model, X, y, n_base_trees=result.n_base_trees, base_score=base_score)
+
def extract_model_param(self, param):
"""
Extracts a specific parameter from the LightGBM model configuration.
@@ -177,6 +199,13 @@ def get_leafs(
This is validated in test_two_tree_base_score_validation().
"""
+ # Validate features
+ expected_features = self.booster_.feature_name()
+ if expected_features:
+ missing = set(expected_features) - set(X.columns)
+ if missing:
+ raise ValueError(f"Input X is missing features the model expects: {missing}")
+
n_trees = self.booster_.num_trees()
_colnames = [f"tree_{i}" for i in range(n_trees)]
@@ -190,14 +219,12 @@ def get_leafs(
# For margin output, get raw scores per tree
# Each tree's prediction is returned as-is (no base_score adjustment needed)
- df_leafs = pd.DataFrame()
-
+ tree_results = []
for i in range(n_trees):
- df_leafs[f"tree_{i}"] = self.model.predict(
- X, raw_score=True, start_iteration=i, num_iteration=1
- )
+ res = self.model.predict(X, raw_score=True, start_iteration=i, num_iteration=1)
+ tree_results.append(res)
- return df_leafs
+ return pd.DataFrame(np.column_stack(tree_results), index=X.index, columns=_colnames)
def extract_leaf_weights(self) -> pd.DataFrame:
"""
@@ -293,36 +320,27 @@ def construct_scorecard(self) -> pd.DataFrame:
f"Invalid leaf index shape {tree_leaf_idx.shape}. Expected {(len(labels), n_trees)}"
)
- df_binning_table = pd.DataFrame()
- for i in range(n_trees):
- index_and_label = pd.concat(
- [
- pd.Series(tree_leaf_idx[:, i], name="leaf_idx"),
- pd.Series(labels, name="label"),
- ],
- axis=1,
- )
- # Create a binning table
- binning_table = (
- index_and_label.groupby("leaf_idx").agg(["sum", "count"]).reset_index()
- ).astype(float)
- binning_table.columns = ["leaf_idx", "Events", "Count"] # type: ignore
- binning_table["tree"] = i
- binning_table["NonEvents"] = binning_table["Count"] - binning_table["Events"]
- binning_table["EventRate"] = binning_table["Events"] / binning_table["Count"]
- binning_table = binning_table[
- ["tree", "leaf_idx", "Events", "NonEvents", "Count", "EventRate"]
- ]
- # Aggregate indices, leafs, and counts of events and non-events
- df_binning_table = pd.concat([df_binning_table, binning_table], axis=0)
+ # Aggregate indices, leafs, and counts of events and non-events
+ tree_leaf_idx_long = pd.DataFrame(tree_leaf_idx).melt(var_name="Tree", value_name="Node")
+ tree_leaf_idx_long["label"] = np.tile(labels.values, tree_leaf_idx.shape[1])
+ binning_table = (
+ tree_leaf_idx_long.groupby(["Tree", "Node"])["label"]
+ .agg(["sum", "count"])
+ .reset_index()
+ )
+ binning_table.columns = pd.Index(["Tree", "Node", "Events", "Count"])
+ df_binning_table = binning_table.assign(
+ NonEvents=lambda df: df["Count"] - df["Events"],
+ EventRate=lambda df: df["Events"] / df["Count"],
+ )[["Tree", "Node", "Events", "NonEvents", "Count", "EventRate"]]
+
# Extract leaf weights (XAddEvidence)
df_x_add_evidence = self.extract_leaf_weights()
self.lgb_scorecard = df_x_add_evidence.merge(
df_binning_table,
- left_on=["Tree", "Node"],
- right_on=["tree", "leaf_idx"],
+ on=["Tree", "Node"],
how="left",
- ).drop(["tree", "leaf_idx"], axis=1)
+ )
self.lgb_scorecard = self.lgb_scorecard[
[
@@ -343,6 +361,7 @@ def construct_scorecard(self) -> pd.DataFrame:
self.lgb_scorecard = self.lgb_scorecard.sort_values(by=["Tree", "Node"]).reset_index(
drop=True
)
+
# Get WOE and IV scores
self.lgb_scorecard["WOE"] = calculate_weight_of_evidence(self.lgb_scorecard)["WOE"]
self.lgb_scorecard["IV"] = calculate_information_value(self.lgb_scorecard)["IV"]
@@ -352,25 +371,67 @@ def construct_scorecard(self) -> pd.DataFrame:
"Tree"
)["Count"].transform("sum")
- self.lgb_scorecard = self.lgb_scorecard[
- [
- "Tree",
- "Node",
- "Feature",
- "Sign",
- "Split",
- "Count",
- "CountPct",
- "NonEvents",
- "Events",
- "EventRate",
- "WOE",
- "IV",
- "XAddEvidence",
- ]
+ # Add TreeSource column for fine-tuned models
+ if self.n_base_trees is not None:
+ self.lgb_scorecard["TreeSource"] = np.where(
+ self.lgb_scorecard["Tree"] < self.n_base_trees, "base", "finetuned"
+ )
+
+ base_columns = [
+ "Tree",
+ "Node",
+ "Feature",
+ "Sign",
+ "Split",
+ "Count",
+ "CountPct",
+ "NonEvents",
+ "Events",
+ "EventRate",
+ "WOE",
+ "IV",
+ "XAddEvidence",
]
+ if self.n_base_trees is not None:
+ base_columns.append("TreeSource")
+
+ self.lgb_scorecard = self.lgb_scorecard[base_columns]
return self.lgb_scorecard
+ def summarize_score_sources(self) -> pd.DataFrame:
+ """Summarize IV contribution split between base and fine-tuned trees.
+
+ Returns:
+ DataFrame with columns: Feature, BaseIV, FinetunedIV, TotalIV
+
+ Raises:
+ ValueError: If n_base_trees is not set or scorecard not constructed.
+ """
+ if self.n_base_trees is None:
+ raise ValueError(
+ "n_base_trees is not set. Use n_base_trees parameter or from_finetune_result()."
+ )
+ if self.lgb_scorecard is None:
+ raise ValueError("Scorecard not constructed yet. Call construct_scorecard() first.")
+ if "TreeSource" not in self.lgb_scorecard.columns:
+ raise ValueError(
+ "TreeSource column not found. Reconstruct scorecard with n_base_trees set."
+ )
+
+ grouped = (
+ self.lgb_scorecard.groupby(["TreeSource", "Feature"])["IV"]
+ .sum()
+ .unstack(level=0, fill_value=0)
+ )
+ result = pd.DataFrame(
+ {
+ "BaseIV": grouped.get("base", 0),
+ "FinetunedIV": grouped.get("finetuned", 0),
+ }
+ )
+ result["TotalIV"] = result["BaseIV"] + result["FinetunedIV"]
+ return result.reset_index().rename(columns={"index": "Feature"})
+
def create_points(
self,
pdo: int = 50,
@@ -415,7 +476,7 @@ def create_points(
if score_type != "XAddEvidence":
raise ValueError(
"Only 'XAddEvidence' score_type is supported for LightGBM. "
- "WOE-based scoring is not recommended due to base_score normalization issues."
+ "For SHAP-based scoring, use predict_score(method='shap') instead."
)
if self.lgb_scorecard is None:
@@ -428,7 +489,7 @@ def create_points(
# Create scorecard with points
scdf = self.lgb_scorecard.copy()
- # Normalize Tree 0 by subtracting base_score (if enabled)
+ # Select score column based on score_type
if use_base_score:
# IMPORTANT: LightGBM sklearn API includes base_score in the first tree's leaves.
# We need to subtract base_score from Tree 0 to normalize all trees to the same scale.
@@ -494,59 +555,193 @@ def _convert_tree_to_points(self, X: pd.DataFrame) -> pd.DataFrame: # pylint: d
# Get leaf indices for all trees
X_leaf_indices = self.get_leafs(X, output_type="leaf_index")
-
- result = pd.DataFrame()
- for col in X_leaf_indices.columns:
- tree_number = col.split("_")[1]
+ n_samples, n_trees = X_leaf_indices.shape
+ points_matrix = np.zeros((n_samples, n_trees))
+ leaf_idx_values = X_leaf_indices.values
+ for t in range(n_trees):
# Get points for this tree
- subset_points_df = self.lgb_scorecard_with_points[
- self.lgb_scorecard_with_points["Tree"] == int(tree_number)
- ].copy()
-
- # Merge leaf indices with points
- merged_df = pd.merge(
- X_leaf_indices[[col]].round(4),
- subset_points_df[["Node", "Points"]],
- left_on=col,
- right_on="Node",
- how="left",
- )
- result[f"Score_{tree_number}"] = merged_df["Points"]
+ tree_points = self.lgb_scorecard_with_points[
+ self.lgb_scorecard_with_points["Tree"] == t
+ ]
+ # Mapping dictionary instead of merge
+ mapping_dict = dict(zip(tree_points["Node"], tree_points["Points"]))
+ points_matrix[:, t] = pd.Series(leaf_idx_values[:, t]).map(mapping_dict).to_numpy()
+ result = pd.DataFrame(
+ points_matrix, index=X.index, columns=[f"Score_{i}" for i in range(n_trees)]
+ )
# Add total score
- result = pd.concat([result, result.sum(axis=1).rename("Score")], axis=1)
+ result["Score"] = points_matrix.sum(axis=1)
return result
- def predict_score(self, X: pd.DataFrame) -> pd.Series: # pylint: disable=C0103
+ def predict_score(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ method: Optional[str] = None,
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.Series:
"""
Predict scores for new data using the constructed scorecard.
Args:
X: Input features
+ method: Scoring method to use:
+ - None (default): Use traditional scorecard-based approach (points lookup)
+ - 'shap': Use SHAP values directly (computes SHAP on-the-fly, no binning table)
+ pdo: Points to Double the Odds (only used for method='shap')
+ target_points: Target score for reference odds (only used for method='shap')
+ target_odds: Reference odds ratio (only used for method='shap')
Returns:
Series of credit scores
Implementation:
- - Maps observations to leaf nodes using predict(pred_leaf=True)
- - Looks up points from scorecard
- - Sums across trees
+ - Default: Maps observations to leaf nodes, looks up points from scorecard, sums across trees
+ - For 'shap': Computes SHAP values on-the-fly, scales directly without binning
"""
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ pdo = self.pdo if self.pdo is not None else pdo
+ target_points = self.target_points if self.target_points is not None else target_points
+ target_odds = self.target_odds if self.target_odds is not None else target_odds
+ return self._predict_score_shap(X, pdo, target_points, target_odds)
+
+ # Default: use traditional scorecard-based approach (points lookup)
+ # Auto-create points if not already created (for backward compatibility)
+ if self.lgb_scorecard_with_points is None:
+ self.create_points(pdo=pdo, target_points=target_points, target_odds=target_odds)
points_df = self._convert_tree_to_points(X)
return pd.Series(points_df["Score"], name="Score")
- def predict_scores(self, X: pd.DataFrame) -> pd.DataFrame: # pylint: disable=C0103
+ def _predict_score_shap(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.Series:
"""
- Predict detailed scores showing contribution from each tree.
+ Predict scores using SHAP values directly (no binning table).
+
+ Args:
+ X: Input features DataFrame
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ Series of predicted scores
+ """
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_lgb(
+ self.model, X
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Validate feature count matches
+ if shap_values.shape[1] != len(X.columns):
+ raise ValueError(
+ f"Feature count mismatch: SHAP values have {shap_values.shape[1]} features, "
+ f"but X has {len(X.columns)} columns"
+ )
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict: dict[str, float | int] = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ # Compute SHAP-based scores using the dedicated function
+ scorecard_df = compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=X.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
+ # Extract final scores
+ return scorecard_df["score"]
+
+ def predict_scores(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ method: Optional[str] = None,
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.DataFrame:
+ """
+ Predict decomposed scores for a given dataset.
Args:
X: Input features
+ method: Scoring method to use:
+ - None (default): Use traditional scorecard-based approach (tree-level decomposition)
+ - 'shap': Use SHAP values directly (feature-level decomposition)
+ pdo: Points to Double the Odds (only used for method='shap')
+ target_points: Target score for reference odds (only used for method='shap')
+ target_odds: Reference odds ratio (only used for method='shap')
Returns:
- DataFrame with tree-level score breakdowns
+ DataFrame with decomposed scores (tree-level for default, feature-level for SHAP)
"""
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ pdo = self.pdo if self.pdo is not None else pdo
+ target_points = self.target_points if self.target_points is not None else target_points
+ target_odds = self.target_odds if self.target_odds is not None else target_odds
+ return self._predict_scores_shap(X, pdo, target_points, target_odds)
+
+ # Default: use traditional scorecard-based approach (tree-level decomposition)
return self._convert_tree_to_points(X)
+ def _predict_scores_shap(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.DataFrame:
+ """
+ Predict decomposed scores using SHAP values (feature-level decomposition).
+
+ Uses intercept-based scoring where intercept and offset are distributed
+ evenly across features, ensuring feature scores sum to the total score.
+
+ Args:
+ X: Input features DataFrame
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ DataFrame with feature-level score contributions and total score
+ """
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_lgb(
+ self.model, X
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict: dict[str, float | int] = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ return compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=X.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
@property
def sql_query(self) -> str:
"""
diff --git a/xbooster/shap_scorecard.py b/xbooster/shap_scorecard.py
new file mode 100644
index 0000000..39fb1a6
--- /dev/null
+++ b/xbooster/shap_scorecard.py
@@ -0,0 +1,222 @@
+"""
+shap_scorecard.py
+
+This module provides functions for computing scores directly from SHAP values
+without using pre-computed binned scorecards. This is useful for models with
+max_depth > 1 where interpretability is challenging.
+
+Author: Denis Burakov
+Github: @deburky
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
+"""
+
+from __future__ import annotations
+
+import importlib
+from typing import Any, Dict, Optional, TYPE_CHECKING
+
+import numpy as np
+import pandas as pd
+
+if TYPE_CHECKING:
+ from xgboost import XGBClassifier
+ from lightgbm import LGBMClassifier
+ from catboost import CatBoostClassifier, Pool
+
+
+def _try_import(module_name: str, *, fromlist: list[str] | None = None) -> Any:
+ """
+ Attempt to import a module or attributes, returning None on failure.
+
+ Args:
+ module_name: Name of the module to import
+ fromlist: Optional list of attribute names to import from the module
+
+ Returns:
+ Imported module, attribute(s), or None if import fails
+ """
+ try:
+ module = importlib.import_module(module_name)
+ if not fromlist:
+ return module
+ attrs = [getattr(module, name) for name in fromlist]
+ return attrs[0] if len(attrs) == 1 else tuple(attrs)
+ except (ImportError, AttributeError):
+ return None
+
+
+# Optional ML library imports (runtime)
+_xgb = _try_import("xgboost")
+_LGBMClassifier = _try_import("lightgbm", fromlist=["LGBMClassifier"])
+
+# Import multiple from same module efficiently
+_cb_imports = _try_import("catboost", fromlist=["Pool", "CatBoostClassifier"])
+_Pool, _CatBoostClassifier = _cb_imports if _cb_imports else (None, None)
+
+
+def compute_shap_scores(
+ shap_values: np.ndarray,
+ base_value: float,
+ feature_names: list,
+ scorecard_dict: Optional[Dict[str, float | int]] = None,
+) -> pd.DataFrame:
+ """
+ Convert SHAP values into a scorecard-like system using intercept-based scoring.
+
+ This function computes feature-level scores from SHAP values and maps them to a
+ traditional scorecard scale (PDO, target points, target odds). The intercept and
+ offset are distributed evenly across all features, ensuring that feature scores
+ sum exactly to the final total score (SAS-style behavior).
+
+ Each feature score includes:
+ - A scaled SHAP contribution
+ - An equal share of the intercept term
+ - An equal share of the offset
+
+ Parameters
+ ----------
+ shap_values : np.ndarray
+ SHAP values of shape (n_samples, n_features).
+ base_value : float
+ SHAP expected value (log-odds). Required for correct scaling.
+ feature_names : list of str
+ Names of features corresponding to columns in shap_values.
+ scorecard_dict : dict, optional
+ Dictionary containing scoring scale parameters:
+ - "pdo": points to double the odds (default=50)
+ - "target_points": reference score (default=600)
+ - "target_odds": reference odds (default=19)
+
+ Returns
+ -------
+ pd.DataFrame
+ DataFrame containing:
+ - {feature}_score columns for each feature
+ - "score" column representing the total score (sum of feature scores)
+
+ Example
+ -------
+ >>> from xbooster.shap import extract_shap_values_xgb, compute_shap_scores
+ >>> shap_values_full = extract_shap_values_xgb(model, X, base_score)
+ >>> shap_values = shap_values_full[:, :-1]
+ >>> base_value = float(np.mean(shap_values_full[:, -1]))
+ >>> scorecard = compute_shap_scores(
+ ... shap_values=shap_values,
+ ... base_value=base_value,
+ ... feature_names=X.columns.tolist(),
+ ... scorecard_dict={"pdo": 50, "target_points": 600, "target_odds": 19},
+ ... )
+ """
+
+ if scorecard_dict is None:
+ scorecard_dict = {
+ "pdo": 50,
+ "target_points": 600,
+ "target_odds": 19,
+ }
+
+ pdo = scorecard_dict["pdo"]
+ target_points = scorecard_dict["target_points"]
+ target_odds = scorecard_dict["target_odds"]
+
+ # Compute scaling factor and offset
+ factor = pdo / np.log(2)
+ offset = target_points - factor * np.log(target_odds)
+
+ # Scale the intercept by factor
+ intercept_scaled = factor * base_value
+
+ # Distribute intercept and offset across features (matches SAS behavior)
+ n_features = shap_values.shape[1]
+
+ # Distribute both intercept and offset
+ intercept_contribution = (-intercept_scaled) / n_features
+ offset_contribution = offset / n_features
+
+ # Vectorized computation of all feature scores at once
+ feature_scores = factor * (-shap_values) + intercept_contribution + offset_contribution
+
+ # Create DataFrame with rounded integer scores
+ feature_score_cols = [f"{f}_score" for f in feature_names]
+ scorecard_df = pd.DataFrame(
+ np.round(feature_scores).astype(np.int64),
+ columns=feature_score_cols,
+ )
+
+ # Total score is the sum of rounded feature scores
+ scorecard_df["score"] = scorecard_df[feature_score_cols].sum(axis=1).astype(int)
+
+ return scorecard_df
+
+
+def extract_shap_values_xgb(
+ model: XGBClassifier,
+ X: pd.DataFrame, # pylint: disable=C0103
+ base_score: float,
+ enable_categorical: bool = False,
+) -> np.ndarray:
+ """
+ Extract SHAP values from XGBoost model using native pred_contribs.
+
+ Args:
+ model: Trained XGBoost classifier
+ X: Input features DataFrame
+ base_score: Base score from the model
+ enable_categorical: Whether categorical features are enabled
+
+ Returns:
+ Array of shape (n_samples, n_features + 1) where last column is base_score.
+ Feature SHAP values are in columns [:, :-1], base_score is in column [:, -1].
+ """
+ if _xgb is None:
+ raise ImportError("xgboost is required for XGBoost SHAP extraction")
+ booster = model.get_booster()
+ scores = np.full((X.shape[0],), base_score)
+ if enable_categorical:
+ dmatrix = _xgb.DMatrix(X, base_margin=scores, enable_categorical=True)
+ else:
+ dmatrix = _xgb.DMatrix(X, base_margin=scores)
+ return booster.predict(dmatrix, pred_contribs=True)
+
+
+def extract_shap_values_lgb(
+ model: LGBMClassifier,
+ X: pd.DataFrame, # pylint: disable=C0103
+) -> np.ndarray:
+ """
+ Extract SHAP values from LightGBM model using native pred_contrib.
+
+ Args:
+ model: Trained LightGBM classifier
+ X: Input features DataFrame
+
+ Returns:
+ Array of shape (n_samples, n_features + 1) where last column is base_score.
+ Feature SHAP values are in columns [:, :-1], base_score is in column [:, -1].
+ """
+ if _LGBMClassifier is None:
+ raise ImportError("lightgbm is required for LightGBM SHAP extraction")
+ return model.predict(X, pred_contrib=True)
+
+
+def extract_shap_values_cb(
+ model: CatBoostClassifier,
+ pool: Pool,
+) -> np.ndarray:
+ """
+ Extract SHAP values from CatBoost model using native get_feature_importance.
+
+ Args:
+ model: Trained CatBoost classifier
+ pool: CatBoost Pool object
+
+ Returns:
+ Array of shape (n_samples, n_features + 1) where last column is base_score.
+ Feature SHAP values are in columns [:, :-1], base_score is in column [:, -1].
+ CatBoost SHAP format: [feature1, feature2, ..., featureN, expected_value]
+ """
+ if _CatBoostClassifier is None or _Pool is None:
+ raise ImportError("catboost is required for CatBoost SHAP extraction")
+ return model.get_feature_importance(type="ShapValues", data=pool)
diff --git a/xbooster/xgb_constructor.py b/xbooster/xgb_constructor.py
index 0376ec9..138f6c3 100644
--- a/xbooster/xgb_constructor.py
+++ b/xbooster/xgb_constructor.py
@@ -9,29 +9,15 @@
scorecard, creating points, and predicting scores based on the constructed
scorecard.
-Example usage:
-
- import pandas as pd
- from sklearn.metrics import roc_auc_score
- import xgboost as xgb
-
- # Instantiate the XGBScorecardConstructor
- scorecard_constructor = XGBScorecardConstructor(
- xgb_model, X.loc[ix_train], y.loc[ix_train]
- )
- # Generate a scorecard
- scorecard_constructor.construct_scorecard()
- xgb_scorecard_with_points = scorecard_constructor.create_points(
- pdo=50, target_points=600, target_odds=50
- )
- # Make predictions using the scorecard
- credit_scores = scorecard_constructor.predict_score(X.loc[ix_test])
- gini = roc_auc_score(y.loc[ix_test], -credit_scores) * 2 - 1
- print(f"Test Gini score: {gini:.2%}")
+Authors: Denis Burakov, Paul Edwards, Juan Antonio Montero de Espinosa
+Github: @deburky, @pedwardsada, @jmonteroers
+License: MIT
+This code is licensed under the MIT License.
+Copyright (c) 2025 xRiskLab
"""
import json
-from typing import Optional
+from typing import Any, Optional
import numpy as np
import pandas as pd
@@ -40,6 +26,7 @@
from ._parser import TreeParser
from ._utils import calculate_information_value, calculate_weight_of_evidence
+from .shap_scorecard import compute_shap_scores, extract_shap_values_xgb
class XGBScorecardConstructor:
@@ -75,7 +62,7 @@ class XGBScorecardConstructor:
```
"""
- def __init__(self, model, X, y): # pylint: disable=R0913, C0103
+ def __init__(self, model, X, y, n_base_trees=None): # pylint: disable=R0913, C0103
if not isinstance(model, xgb.XGBClassifier):
raise TypeError("model must be an instance of xgboost.XGBClassifier")
self.model = model
@@ -94,9 +81,33 @@ def __init__(self, model, X, y): # pylint: disable=R0913, C0103
self.precision_points = None
self.score_type = None
self._sql_query = None
+
+ # Fine-tuning awareness
+ if n_base_trees is not None:
+ total_trees = self.booster_.num_boosted_rounds()
+ if n_base_trees > total_trees:
+ raise ValueError(
+ f"n_base_trees ({n_base_trees}) exceeds total trees ({total_trees})"
+ )
+ self.n_base_trees = n_base_trees
+
if self.max_depth > 1:
self.extract_decision_nodes()
+ @classmethod
+ def from_finetune_result(cls, result, X, y):
+ """Create constructor from a FineTuneResult.
+
+ Args:
+ result: FineTuneResult from finetune_xgb().
+ X: Training/fine-tuning features.
+ y: Training/fine-tuning labels.
+
+ Returns:
+ XGBScorecardConstructor with n_base_trees set.
+ """
+ return cls(result.model, X, y, n_base_trees=result.n_base_trees)
+
def extract_model_param(self, param):
"""
Extracts a specific parameter from the XGBoost model configuration.
@@ -194,6 +205,13 @@ def get_leafs(
https://arxiv.org/pdf/2304.13761.pdf
"""
+ # Validate features
+ expected_features = self.booster_.feature_names
+ if expected_features is not None:
+ missing = set(expected_features) - set(X.columns)
+ if missing:
+ raise ValueError(f"Input X is missing features the model expects: {missing}")
+
n_rounds = self.booster_.num_boosted_rounds()
scores = np.full((X.shape[0],), self.base_score) # pylint: disable=C0103
xgb_features = xgb.DMatrix(X, base_margin=scores) # pylint: disable=C0103
@@ -201,17 +219,16 @@ def get_leafs(
if output_type == "leaf_index":
# Predict leaf index
tree_leaf_idx = self.booster_.predict(xgb_features, pred_leaf=True)
- return pd.DataFrame(tree_leaf_idx, columns=_colnames)
-
- df_leafs = pd.DataFrame()
+ # Convert to integer to match LightGBM behavior (7 instead of 7.0)
+ return pd.DataFrame(tree_leaf_idx, columns=_colnames).astype(int)
+ tree_results = []
for i in range(n_rounds):
- # Predict margin
tree_leafs = (
self.booster_.predict(xgb_features, iteration_range=(i, i + 1), output_margin=True)
- scores
)
- df_leafs[f"tree_{i}"] = tree_leafs.flatten()
- return df_leafs
+ tree_results.append(tree_leafs.flatten())
+ return pd.DataFrame(np.column_stack(tree_results), index=X.index, columns=_colnames)
def extract_leaf_weights(self) -> pd.DataFrame:
"""
@@ -335,7 +352,6 @@ def construct_scorecard(self) -> pd.DataFrame: # pylint: disable=R0914
n_rounds = self.booster_.num_boosted_rounds()
labels = xgb_features_and_labels.get_label()
- df_binning_table = pd.DataFrame()
# TODO: Refactor this part to re-use the get_leafs method in the future
# Summing margins from a booster, adopted from here:
# https://xgboost.readthedocs.io/en/latest/python/examples/individual_trees.html
@@ -346,57 +362,33 @@ def construct_scorecard(self) -> pd.DataFrame: # pylint: disable=R0914
f"Invalid leaf index shape {tree_leaf_idx.shape}. Expected {(len(labels), n_rounds)}"
)
- for i in range(n_rounds):
- # Get counts of events and non-events
- index_and_label = pd.concat(
- [
- pd.Series(tree_leaf_idx[:, i], name="leaf_idx"),
- pd.Series(labels, name="label"),
- ],
- axis=1,
- )
- # Create a binning table
- binning_table = (
- index_and_label.groupby("leaf_idx").agg(["sum", "count"]).reset_index()
- ).astype(float)
- binning_table.columns = ["leaf_idx", "Events", "Count"] # type: ignore
- binning_table["tree"] = i
- binning_table["NonEvents"] = binning_table["Count"] - binning_table["Events"]
- binning_table["EventRate"] = binning_table["Events"] / binning_table["Count"]
- binning_table = binning_table[
- ["tree", "leaf_idx", "Events", "NonEvents", "Count", "EventRate"]
- ]
- # Aggregate indices, leafs, and counts of events and non-events
- df_binning_table = pd.concat([df_binning_table, binning_table], axis=0)
+ tree_leaf_idx_long = pd.DataFrame(tree_leaf_idx).melt(var_name="Tree", value_name="Node")
+ tree_leaf_idx_long["label"] = np.tile(labels, tree_leaf_idx.shape[1])
+ binning_table = (
+ tree_leaf_idx_long.groupby(["Tree", "Node"])["label"]
+ .agg(["sum", "count"])
+ .reset_index()
+ )
+ binning_table.columns = pd.Index(["Tree", "Node", "Events", "Count"])
+ df_binning_table = binning_table.assign(
+ NonEvents=lambda df: df["Count"] - df["Events"],
+ EventRate=lambda df: df["Events"] / df["Count"],
+ )[["Tree", "Node", "Events", "NonEvents", "Count", "EventRate"]]
+
# Extract leaf weights (XAddEvidence)
df_x_add_evidence = self.extract_leaf_weights()
self.xgb_scorecard = df_x_add_evidence.merge(
df_binning_table,
- left_on=["Tree", "Node"],
- right_on=["tree", "leaf_idx"],
+ on=["Tree", "Node"],
how="left",
- ).drop(["tree", "leaf_idx"], axis=1)
-
- self.xgb_scorecard = self.xgb_scorecard[
- [
- "Tree",
- "Node",
- "Feature",
- "Sign",
- "Split",
- "Count",
- "NonEvents",
- "Events",
- "EventRate",
- "XAddEvidence",
- ]
- ]
+ )
# Sort by Tree and Node
self.xgb_scorecard = self.xgb_scorecard.sort_values(by=["Tree", "Node"]).reset_index(
drop=True
)
+
# Get WOE and IV scores
self.xgb_scorecard["WOE"] = calculate_weight_of_evidence(self.xgb_scorecard)["WOE"]
self.xgb_scorecard["IV"] = calculate_information_value(self.xgb_scorecard)["IV"]
@@ -409,26 +401,67 @@ def construct_scorecard(self) -> pd.DataFrame: # pylint: disable=R0914
# Retrieve a detailed split
self.xgb_scorecard = self.add_detailed_split(dataframe=self.xgb_scorecard)
- self.xgb_scorecard = self.xgb_scorecard[
- [
- "Tree",
- "Node",
- "Feature",
- "Sign",
- "Split",
- "Count",
- "CountPct",
- "NonEvents",
- "Events",
- "EventRate",
- "WOE",
- "IV",
- "XAddEvidence",
- "DetailedSplit",
- ]
+ # Add TreeSource column for fine-tuned models
+ if self.n_base_trees is not None:
+ self.xgb_scorecard["TreeSource"] = np.where(
+ self.xgb_scorecard["Tree"] < self.n_base_trees, "base", "finetuned"
+ )
+
+ # Build column list
+ base_columns = [
+ "Tree",
+ "Node",
+ "Feature",
+ "Sign",
+ "Split",
+ "Count",
+ "CountPct",
+ "NonEvents",
+ "Events",
+ "EventRate",
+ "WOE",
+ "IV",
+ "XAddEvidence",
+ "DetailedSplit",
]
+ if self.n_base_trees is not None:
+ base_columns.append("TreeSource")
+
+ return self.xgb_scorecard[base_columns]
- return self.xgb_scorecard
+ def summarize_score_sources(self) -> pd.DataFrame:
+ """Summarize IV contribution split between base and fine-tuned trees.
+
+ Returns:
+ DataFrame with columns: Feature, BaseIV, FinetunedIV, TotalIV
+
+ Raises:
+ ValueError: If n_base_trees is not set or scorecard not constructed.
+ """
+ if self.n_base_trees is None:
+ raise ValueError(
+ "n_base_trees is not set. Use n_base_trees parameter or from_finetune_result()."
+ )
+ if self.xgb_scorecard is None:
+ raise ValueError("Scorecard not constructed yet. Call construct_scorecard() first.")
+ if "TreeSource" not in self.xgb_scorecard.columns:
+ raise ValueError(
+ "TreeSource column not found. Reconstruct scorecard with n_base_trees set."
+ )
+
+ grouped = (
+ self.xgb_scorecard.groupby(["TreeSource", "Feature"])["IV"]
+ .sum()
+ .unstack(level=0, fill_value=0)
+ )
+ result = pd.DataFrame(
+ {
+ "BaseIV": grouped.get("base", 0),
+ "FinetunedIV": grouped.get("finetuned", 0),
+ }
+ )
+ result["TotalIV"] = result["BaseIV"] + result["FinetunedIV"]
+ return result.reset_index().rename(columns={"index": "Feature"})
def create_points( # pylint: disable=R0913
self,
@@ -459,6 +492,7 @@ def create_points( # pylint: disable=R0913
Options:
- 'XAddEvidence': Uses XGBoost's log-odds score (leaf weight or margin).
- 'WOE': Uses Weight-of-Evidence (WOE) score (Experimental).
+ - 'SHAP': Uses SHAP values aggregated per leaf (Alpha - for depth>1 models).
scorecard: pd.DataFrame, optional
An external scorecard to use for creating points, by default None.
@@ -471,6 +505,11 @@ def create_points( # pylint: disable=R0913
and divided by the maximum number of nodes per tree to make them more
similar to the range of XAddEvidence scores.
+ For SHAP score type (Alpha), SHAP values are aggregated per leaf using
+ weighted average. This is particularly useful for models with max_depth > 1
+ where interpretability becomes challenging. SHAP values provide feature
+ attribution that accounts for feature interactions.
+
References:
NVIDIA GTC Talk "Machine Learning in Retail Credit Risk" by Paul Edwards.
https://www.nvidia.com/ko-kr/on-demand/session/gtcspring21-s31327/
@@ -493,29 +532,38 @@ def create_points( # pylint: disable=R0913
self.score_type = score_type
if score_type not in {"XAddEvidence", "WOE"}:
- raise ValueError("constructor.py: score must be one of 'XAddEvidence' or 'WOE'")
+ raise ValueError(
+ "constructor.py: score must be one of 'XAddEvidence' or 'WOE'. "
+ "For SHAP-based scoring, use predict_score(method='shap') instead."
+ )
try:
if self.xgb_scorecard is None:
raise ValueError("xgb_scorecard is None and dataframe is None.")
- # If we use score_type == 'WOE', we need to calculate the initial
- # odds, O(H)
- base_score = (
- self.y.mean() / (1 - self.y.mean()) if score_type == "WOE" else self.base_score
- )
- scdf = (
- self.xgb_scorecard.copy()
- .assign(
- Score=np.where(
- score_type == "XAddEvidence",
- self.xgb_scorecard.XAddEvidence,
- (
- (self.xgb_scorecard.WOE * self.learning_rate)
- # TODO: Make adjustable in the future
- / self.xgb_scorecard["Node"].max()
- ),
- )
+ # Get base score based on score_type
+ if score_type == "SHAP":
+ raise ValueError(
+ "SHAP score_type is no longer supported in create_points(). "
+ "Use predict_score(method='shap') instead."
+ )
+ elif score_type == "WOE":
+ # For WOE, use average event rate
+ base_score = self.y.mean() / (1 - self.y.mean())
+ score_col = (
+ (self.xgb_scorecard.WOE * self.learning_rate)
+ # TODO: Make adjustable in the future
+ / self.xgb_scorecard["Node"].max()
)
- .assign(base_score=base_score) # pylint: disable=E1101
+ elif score_type == "XAddEvidence":
+ # For XAddEvidence, use model's base_score
+ base_score = self.base_score
+ score_col = self.xgb_scorecard.XAddEvidence
+ else:
+ # For XAddEvidence, use model's base_score
+ base_score = self.base_score
+ raise ValueError(f"Unknown score_type: {score_type}")
+
+ scdf = (
+ self.xgb_scorecard.copy().assign(Score=score_col).assign(base_score=base_score) # pylint: disable=E1101
)
except KeyError as e:
raise ValueError(f"Invalid columns in xgb_scorecard: {e}") from e
@@ -523,11 +571,10 @@ def create_points( # pylint: disable=R0913
factor = pdo / np.log(2)
offset = target_points - factor * np.log(target_odds)
- scdf["ScaledScore"] = factor * scdf.Score + logit(scdf.base_score)
-
+ # Scale scores with base_score adjustment
if score_type == "XAddEvidence":
scdf["ScaledScore"] = factor * scdf.Score + logit(scdf.base_score)
- else:
+ else: # WOE
scdf["ScaledScore"] = factor * scdf.Score
# Set the index to the Tree number
@@ -566,50 +613,194 @@ def _convert_tree_to_points(self, X): # pylint: disable=C0103
pd.DataFrame: The DataFrame containing scores per tree and the total score.
"""
+ if self.xgb_scorecard_with_points is None:
+ raise ValueError(
+ "No scorecard with points has been created yet. Call create_points() first."
+ )
X_leaf_weights = self.get_leafs(X, output_type="leaf_index") # pylint: disable=C0103
- result = pd.DataFrame()
- for col in X_leaf_weights.columns:
- tree_number = col.split("_")[1]
- if self.xgb_scorecard_with_points is not None:
- subset_points_df = self.xgb_scorecard_with_points[
- self.xgb_scorecard_with_points["Tree"] == int(tree_number)
- ].copy()
- merged_df = pd.merge(
- X_leaf_weights[[col]].round(4),
- subset_points_df[["Node", "Points"]],
- left_on=col,
- right_on="Node",
- how="left",
- )
- result[f"Score_{tree_number}"] = merged_df["Points"]
- result = pd.concat([result, result.sum(axis=1).rename("Score")], axis=1)
+ n_samples, n_rounds = X_leaf_weights.shape
+ points_matrix = np.zeros((n_samples, n_rounds))
+ leaf_idx_values = X_leaf_weights.values
+ for t in range(n_rounds):
+ # Get points for this tree
+ tree_points = self.xgb_scorecard_with_points[
+ self.xgb_scorecard_with_points["Tree"] == t
+ ]
+ # Mapping dictionary instead of merge
+ mapping_dict = dict(zip(tree_points["Node"], tree_points["Points"]))
+ points_matrix[:, t] = pd.Series(leaf_idx_values[:, t]).map(mapping_dict).to_numpy()
+
+ result = pd.DataFrame(
+ points_matrix, index=X.index, columns=[f"Score_{i}" for i in range(n_rounds)]
+ )
+ # Add total score
+ result["Score"] = points_matrix.sum(axis=1)
return result
- def predict_score(self, X: pd.DataFrame) -> pd.Series: # pylint: disable=C0103
+ def predict_score(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ method: Optional[str] = None,
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.Series:
"""
Predicts the score for a given dataset using the constructed scorecard.
Parameters:
- - X (pd.DataFrame): Features of the dataset.
+ - X: Features of the dataset.
+ - method: Scoring method to use:
+ - None (default): Use traditional scorecard-based approach (points lookup)
+ - 'shap': Use SHAP values directly (computes SHAP on-the-fly, no binning table)
+ - pdo: Points to Double the Odds (only used for method='shap')
+ - target_points: Target score for reference odds (only used for method='shap')
+ - target_odds: Reference odds ratio (only used for method='shap')
Returns:
- pd.Series: Predicted scores.
"""
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ pdo = self.pdo if self.pdo is not None else pdo
+ target_points = self.target_points if self.target_points is not None else target_points
+ target_odds = self.target_odds if self.target_odds is not None else target_odds
+ return self._predict_score_shap(X, pdo, target_points, target_odds)
+
+ # Default: use traditional scorecard-based approach (points lookup)
+ # Auto-create points if not already created (for backward compatibility)
+ if self.xgb_scorecard_with_points is None:
+ self.create_points(pdo=pdo, target_points=target_points, target_odds=target_odds)
points_df = self._convert_tree_to_points(X)
return pd.Series(points_df["Score"], name="Score")
- def predict_scores(self, X: pd.DataFrame) -> pd.DataFrame: # pylint: disable=C0103
+ def _predict_score_shap(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.Series:
"""
- Predicts the score for a given dataset using the constructed scorecard.
+ Predict scores using SHAP values directly (no binning table).
+
+ Args:
+ X: Input features DataFrame
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ Series of predicted scores
+ """
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_xgb(
+ self.model, X, self.base_score, self.enable_categorical
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Validate feature count matches
+ if shap_values.shape[1] != len(X.columns):
+ raise ValueError(
+ f"Feature count mismatch: SHAP values have {shap_values.shape[1]} features, "
+ f"but X has {len(X.columns)} columns"
+ )
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict: dict[str, float | int] = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ # Compute SHAP-based scores using the dedicated function
+ scorecard_df = compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=X.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
+ # Extract final scores
+ return scorecard_df["score"]
+
+ def predict_scores(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ method: Optional[str] = None,
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.DataFrame:
+ """
+ Predicts decomposed scores for a given dataset.
Parameters:
- - X (pd.DataFrame): Features of the dataset.
+ - X: Features of the dataset.
+ - method: Scoring method to use:
+ - None (default): Use traditional scorecard-based approach (tree-level decomposition)
+ - 'shap': Use SHAP values directly (feature-level decomposition)
+ - pdo: Points to Double the Odds (only used for method='shap')
+ - target_points: Target score for reference odds (only used for method='shap')
+ - target_odds: Reference odds ratio (only used for method='shap')
Returns:
- - pd.Series: Predicted scores.
+ - pd.DataFrame: Decomposed scores (tree-level for default, feature-level for SHAP)
"""
+ if method == "shap":
+ # Use stored PDO parameters if available (from create_points), otherwise use provided/defaults
+ pdo = self.pdo if self.pdo is not None else pdo
+ target_points = self.target_points if self.target_points is not None else target_points
+ target_odds = self.target_odds if self.target_odds is not None else target_odds
+ return self._predict_scores_shap(X, pdo, target_points, target_odds)
+
+ # Default: use traditional scorecard-based approach (tree-level decomposition)
return self._convert_tree_to_points(X)
+ def _predict_scores_shap(
+ self,
+ X: pd.DataFrame, # pylint: disable=C0103
+ pdo: int = 50,
+ target_points: int = 600,
+ target_odds: int = 19,
+ ) -> pd.DataFrame:
+ """
+ Predict decomposed scores using SHAP values (feature-level decomposition).
+
+ Uses intercept-based scoring where intercept and offset are distributed
+ evenly across features, ensuring feature scores sum to the total score.
+
+ Args:
+ X: Input features DataFrame
+ pdo: Points to Double the Odds
+ target_points: Target score for reference odds
+ target_odds: Reference odds ratio
+
+ Returns:
+ DataFrame with feature-level score contributions and total score
+ """
+ # Extract SHAP values for input features
+ shap_values_full = extract_shap_values_xgb(
+ self.model, X, self.base_score, self.enable_categorical
+ ) # Shape: (n_samples, n_features + 1)
+ shap_values = shap_values_full[:, :-1] # Feature contributions
+ base_value = float(np.mean(shap_values_full[:, -1])) # Base value (expected value)
+
+ # Use the SHAP scorecard computation function
+ scorecard_dict: dict[str, float | int] = {
+ "pdo": pdo,
+ "target_points": target_points,
+ "target_odds": target_odds,
+ }
+
+ return compute_shap_scores(
+ shap_values=shap_values,
+ base_value=base_value,
+ feature_names=X.columns.tolist(),
+ scorecard_dict=scorecard_dict,
+ )
+
@property
def sql_query(self):
"""
@@ -832,16 +1023,21 @@ def query_count(
self.xgb_scorecard_intv["NonEvents"] = np.nan
X_event = self.X.loc[self.y == 1]
X_nonevent = self.X.loc[self.y == 0]
- for bin in self.xgb_scorecard_intv.itertuples():
- has_missing = "Missing" in bin.Bin
- self.xgb_scorecard_intv.loc[bin.Index, "Count"] = query_count(
- self.X, bin.Feature, bin.Left, bin.Right, has_missing
+ for row_item in self.xgb_scorecard_intv.itertuples():
+ row_bin2: Any = row_item
+ bin_str = str(row_bin2.Bin)
+ feat = str(row_bin2.Feature)
+ left = float(row_bin2.Left)
+ right = float(row_bin2.Right)
+ has_missing = "Missing" in bin_str
+ self.xgb_scorecard_intv.loc[row_bin2.Index, "Count"] = query_count(
+ self.X, feat, left, right, has_missing
)
- self.xgb_scorecard_intv.loc[bin.Index, "Events"] = query_count(
- X_event, bin.Feature, bin.Left, bin.Right, has_missing
+ self.xgb_scorecard_intv.loc[row_bin2.Index, "Events"] = query_count(
+ X_event, feat, left, right, has_missing
)
- self.xgb_scorecard_intv.loc[bin.Index, "NonEvents"] = query_count(
- X_nonevent, bin.Feature, bin.Left, bin.Right, has_missing
+ self.xgb_scorecard_intv.loc[row_bin2.Index, "NonEvents"] = query_count(
+ X_nonevent, feat, left, right, has_missing
)
# Add 'CountPct as proportion of total observations