Skip to content

Conversation

@RektPunk
Copy link
Contributor

@RektPunk RektPunk commented Dec 20, 2025

Description

This PR optimizes the scoring engine by replacing the inefficient iterative pd.merge operations with Vectorized Dictionary Mapping.

The previous implementation performed a pd.merge within a loop for every tree, which caused performance bottlenecks and memory overhead during the scoring of large datasets. The new approach pre-extracts leaf indices as a NumPy array and applies score mapping using map, resulting in a speedup.

Key Changes

  • Vectorized Lookup: Switched from iterative pd.merge to NumPy-based dictionary mapping.

Performance Benchmark

Measured using %%timeit on the example ipynb:

Method Execution Time (Mean ± Std. Dev.) Speedup
Original (Iterative) 11.6 ms ± 206 μs 1.0x
Improved (Vectorized) 2.71 ms ± 31.6 μs ~4x faster

Summary by Sourcery

Optimize tree-to-points conversion in the LightGBM constructor for faster score computation by replacing per-tree DataFrame merges with a vectorized mapping approach.

Enhancements:

  • Replace per-tree pandas merge operations with a vectorized NumPy-based mapping from leaf indices to point scores to improve performance of score calculation.
  • Construct score matrices directly from NumPy arrays and compute total scores via array summation instead of DataFrame concatenation.

@sourcery-ai
Copy link

sourcery-ai bot commented Dec 20, 2025

Reviewer's guide (collapsed on small PRs)

Reviewer's Guide

Refactors _convert_tree_to_points to replace per-tree pandas merges with a NumPy-based vectorized mapping from leaf indices to point scores, building the score matrix in memory and computing the total score via array sums for a substantial performance gain.

Flow diagram for optimized _convert_tree_to_points vectorized scoring

flowchart TD
    A[input_X_DataFrame] --> B[get_leafs_X_leaf_indices]
    B --> C[get_shape_n_samples_n_trees]
    C --> D[init_points_matrix_zeros_n_samples_by_n_trees]
    C --> E[get_leaf_idx_values_from_X_leaf_indices]

    D --> F[for_each_tree_index_t_0_to_n_trees_minus_1]
    E --> F

    subgraph Per_tree_vectorized_mapping
        F --> G[filter_lgb_scorecard_with_points_where_Tree_equals_t]
        G --> H[build_mapping_dict_Node_to_Points]
        H --> I[apply_np_vectorize_mapping_to_leaf_idx_values_column_t]
        I --> J[assign_result_to_points_matrix_all_rows_column_t]
    end

    J --> K[after_loop_build_result_DataFrame_from_points_matrix]
    K --> L[set_result_index_to_X_index]
    L --> M[set_result_columns_to_Score_0_to_Score_n_trees_minus_1]
    M --> N[compute_total_Score_as_rowwise_sum_of_points_matrix]
    N --> O[add_Score_column_to_result]
    O --> P[return_result]
Loading

File-Level Changes

Change Details Files
Replace per-tree pd.merge loop with NumPy-based vectorized mapping from leaf indices to scorecard points.
  • Extract leaf indices as a NumPy array and preallocate a points matrix shaped (n_samples, n_trees).
  • Iterate over trees by numeric index, filter the scorecard for each tree, and build a node-to-points mapping dictionary.
  • Apply np.vectorize(mapping_dict.get) over leaf index columns to populate the points matrix instead of using pandas merges.
  • Construct the result DataFrame from the points matrix with Score_<tree> columns and original index preserved.
  • Compute the total Score column directly from the NumPy points matrix sum instead of using pd.concat with a Series sum.
xbooster/lgb_constructor.py

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The new lookup uses Tree == t where t is a zero-based column index, whereas the previous version derived tree_number from the column name; please double-check that lgb_scorecard_with_points['Tree'] uses the same zero-based indexing and ordering as X_leaf_indices.columns to avoid misaligned scores.
  • The old implementation applied .round(4) to leaf indices before merging, but the new code does exact dictionary lookups on leaf_idx_values; if leaf/node identifiers are floats, you may need to preserve the rounding (or another normalization) to avoid missing matches.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The new lookup uses `Tree == t` where `t` is a zero-based column index, whereas the previous version derived `tree_number` from the column name; please double-check that `lgb_scorecard_with_points['Tree']` uses the same zero-based indexing and ordering as `X_leaf_indices.columns` to avoid misaligned scores.
- The old implementation applied `.round(4)` to leaf indices before merging, but the new code does exact dictionary lookups on `leaf_idx_values`; if leaf/node identifiers are floats, you may need to preserve the rounding (or another normalization) to avoid missing matches.

## Individual Comments

### Comment 1
<location> `xbooster/lgb_constructor.py:500-502` </location>
<code_context>
+        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
+            ]
</code_context>

<issue_to_address>
**issue (bug_risk):** Using the loop index `t` as the `Tree` filter may be brittle if column order / naming changes.

Previously, `tree_number` came from the column name and was used directly in the `Tree` filter, so the mapping depended only on the naming convention. Now the code assumes the column order in `X_leaf_indices` matches `Tree == t` for `t in range(n_trees)`. If columns are reordered, some trees are missing, or `Tree` values are not `0..n_trees-1`, scores could be assigned to the wrong tree with no error. Consider restoring the name-based mapping or otherwise explicitly aligning column names to `Tree` values instead of relying on positional order.
</issue_to_address>

### Comment 2
<location> `xbooster/lgb_constructor.py:506-507` </location>
<code_context>
+                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] = np.vectorize(mapping_dict.get)(leaf_idx_values[:, t])

+        result = pd.DataFrame(
</code_context>

<issue_to_address>
**issue (bug_risk):** Dropping the previous rounding on leaf indices may introduce subtle key-mismatch issues.

Previously, `X_leaf_indices[[col]].round(4)` ensured leaf indices matched the `Node` keys despite floating point noise. Now, `mapping_dict.get(leaf_idx_values[:, t])` relies on raw float values, so slight precision differences could cause failed lookups and `NaN`s, altering results. If the rounding was added for this reason, consider restoring a consistent normalization step (e.g., rounding or casting to int) before using these values as dict keys.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

@RektPunk RektPunk changed the title [Feature] make faster _convert_tree_to_points use np.vectorize instead of merge [Refactor] make faster _convert_tree_to_points use np.vectorize instead of merge Dec 20, 2025
@RektPunk RektPunk changed the title [Refactor] make faster _convert_tree_to_points use np.vectorize instead of merge [Refactor] make faster _convert_tree_to_points use np.vectorize instead of merge in xbooster/lgb_constructor.py Dec 20, 2025
xRiskLab added a commit that referenced this pull request Dec 21, 2025
- Update version to 0.2.8rc1
- Add CHANGELOG entry documenting:
  * SHAP integration features (alpha)
  * Performance improvements from @RektPunk (PRs #10, #11, #13, #14)
- Release candidate includes both SHAP features and performance optimizations
@xRiskLab
Copy link
Owner

Great work on these performance optimizations!

All your changes from PRs #10, #11, #13, and #14 have been merged into the release/v0.2.8rc1 release candidate branch. Your optimizations are now part of the upcoming v0.2.8 release.

What's included:

Impact:

  • Replaced loop-based pd.concat() operations with vectorized melt() + groupby()
  • Replaced pd.merge() in loops with dictionary mapping using .map()
  • Significant performance improvements for models with many trees
  • All changes maintain backward compatibility and numerical equivalence

The RC branch (release/v0.2.8rc1) combines your performance improvements with the new SHAP integration features. Once testing is complete, this will be released as v0.2.8.

Thank you for these excellent contributions!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants