Skip to content

fix(pp): index top peaks by region in normalize_peaks - #240

Merged
nkempynck merged 8 commits into
mainfrom
fix/normalize-peaks-gini-indexing
Aug 18, 2026
Merged

fix(pp): index top peaks by region in normalize_peaks#240
nkempynck merged 8 commits into
mainfrom
fix/normalize-peaks-gini-indexing

Conversation

@nkempynck

@nkempynck nkempynck commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

A bit of a concerning indexing bug that luckily does not have huge effects.

Sorting the thresholded column gives positions within that column, which are
offset from region indices by the number of regions dropped ahead of them. Those
positions were used to index the full region matrix, so the Gini scores deciding
the low-Gini subset were read off unrelated regions whenever a region fell at or
below peak_threshold.

That in effect skipped the broad/specific test: the peaks averaged for the weight
were the right top peaks, but were never checked for being broad. Conversely the
same wrong indices were stored, so the returned region DataFrame listed genuinely
broad regions that were not top peaks.

Carry the kept region indices through the top-k selection instead.

Weights move ~1% on average (2.8% max) on the mouse cortex tutorial data.

Second commit: normalize_peaks now raises when a cell type ends up with no
selected peaks, instead of giving it an inf weight (or nan for all of them, if
it happens to every cell type) and writing that into .X. Reachable by raising
gini_std_threshold past the point where no top peak still counts as broad, which
is dataset dependent — 2.5 on the mouse cortex tutorial data, right next to the
2.0 in the docstring example.

@nkempynck

Copy link
Copy Markdown
Collaborator Author

thanks to @UCDNJJ for noticing!!

Sorting the thresholded column gives positions within that column, which
are offset from region indices by the number of regions dropped ahead of
them. Those positions were used to index the full region matrix, so the
Gini scores deciding the low-Gini subset were read off unrelated regions
whenever a region fell at or below peak_threshold.

That in effect skipped the broad/specific test: the peaks averaged for the
weight were the right top peaks, but were never checked for being broad.
Conversely the same wrong indices were stored, so the returned region
DataFrame listed genuinely broad regions that were not top peaks.

Carry the kept region indices through the top-k selection instead.

Weights move ~1% on average (2.8% max) on the mouse cortex tutorial data.
A cell type with no selected peaks got top_k_mean 0, so its weight was
max_mean/0 = inf, or nan for every cell type at once when none had any.
Both went straight into .X behind a numpy RuntimeWarning.

Reachable without any sparsity by raising gini_std_threshold past the
point where no top peak still counts as broad. On the mouse cortex
tutorial data that cliff sits at 2.5, next to the 2.0 in the docstring
example, and its position depends on the dataset's Gini distribution.
@nkempynck
nkempynck force-pushed the fix/normalize-peaks-gini-indexing branch from f994654 to a1b0ee7 Compare August 17, 2026 09:57
@nkempynck

Copy link
Copy Markdown
Collaborator Author

still have to update the tutorial notebook

@nkempynck
nkempynck requested review from casblaauw and a lite review from Copilot August 17, 2026 10:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes correctness issues in crested.pp.normalize_peaks where peak indices were mis-mapped after thresholding (causing Gini-based broad/specific filtering and returned peak lists to be computed from unrelated regions), and hardens normalization by raising when a cell type ends up with no selected peaks (instead of silently writing inf/nan into .X).

Changes:

  • Correctly carries original region indices through top-k selection when peak_threshold filters regions prior to sorting.
  • Raises a clear ValueError when no peaks pass top-k + Gini selection for one or more cell types.
  • Adds closed-form tests covering the indexing bug and the new error behavior; documents both changes in the changelog.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 1 comment.

File Description
src/crested/pp/_normalization.py Fixes index mapping for top-k selection; adds an explicit error when normalization weights would be undefined.
tests/test_pp.py Adds analytic regression tests for correct top-peak/Gini coupling and for raising on empty selections.
docs/changelog.md Documents the indexing bugfix, the new error behavior, and the added regression test.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/crested/pp/_normalization.py Outdated
The guard test only exercised the case where no top peak counts as broad,
which leaves top_indices non-empty. Add the two routes that empty it: a
cell type entirely at or below peak_threshold, and top_k_percent rounding
the selection to zero.

Both stay a clean shape-(0,) reduction over the cell type axis and reach
the intended error, rather than tripping numpy's zero-size reduction as a
PR review suggested. Matching on the message distinguishes the two, since
numpy raises ValueError for that as well.
Rerun after the normalize_peaks indexing fix. The returned region
DataFrame now lists the cell types' top peaks rather than arbitrary broad
regions, and the weights plot reflects the corrected values.
@nkempynck

Copy link
Copy Markdown
Collaborator Author

ready to merge if all agree

Comment on lines +149 to +150
"text": [
"Downloading file 'data/mouse_biccn/bigwigs_cut_sites.tar.gz' from 'https://resources.aertslab.org/CREsted/data/mouse_biccn/bigwigs_cut_sites.tar.gz' to '/home/VIB.LOCAL/niklas.kempynck/.cache/crested'.\n",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

avoid exact paths to our storage. Just remove notebook output cells that include these.

@casblaauw

Copy link
Copy Markdown
Collaborator

I find the code a bit confusing to follow and the comments are very verbose as always with generated stuff, so I'll clean up the comments a bit

@casblaauw

Copy link
Copy Markdown
Collaborator

Had a look over it and adjusted some stuff, here's a summary:

  • Separated it out into sections with more comments and renamed variables to be clearer to me
  • Replaced saving as 0 (and then trying to check that with readying arbitrary ints as booleans, which feels risky) for failed classes with just saving the class name
  • Reduced gini to 1D vector once rather than doing it separately every time
  • Drop redundant per-class gini calculating (since gini is an independent property of every region)
  • Dropped a redundant duplicated sort/argsort call (sorting filtered_col for both sorted_col and top_indices)
  • Dropped creating of lists in the loop that aren't used elsewhere
  • Also raise an error if adata.obsm['weights'] already exists, rather than trying to run the normalization on already-normalized data

Here's a version of my reworked inner filtering step with annotations of which indices belong to which, so that you can check my logic:

    for i in range(target_matrix.shape[1]):
        # Apply peak_threshold: minimum peak height filtering for this cell type
        filtered_indices = np.where(target_matrix[:, i] > peak_threshold)[0] # filtered_indices indices into target_matrix
        filtered_col = target_matrix[filtered_indices, i]

        # Apply top_k_percent: Get top k of the values that pass threshold
        top_k_index = int(len(filtered_indices) * top_k_percent)
        sorted_filtered_indices = np.argsort(filtered_col)[::-1] # sorted_filtered_indices indices into filtered_[col|indices]
        top_indices = filtered_indices[sorted_filtered_indices[:top_k_index]] # top_indices is a subset of filtered_indices, so indices into target_matrix
        top_col = target_matrix[:, i][top_indices] # aka filtered_col[sorted_filtered_indices[:top_k_index]]

        # Apply gini_threshold: Retrieve gini scores for the regions passing previous filters and keep those below threshold
        gini_scores = overall_gini_scores[top_indices]
        low_gini_mask = gini_scores < gini_threshold # indices into top_[col|indices]

        # Save final scores
        if low_gini_mask.sum() > 0:
            top_k_mean = np.mean(top_col[low_gini_mask])
            all_low_gini_indices.update(top_indices[low_gini_mask])
            top_k_percent_means.append(top_k_mean)
        else:
            # No regions left after all filtering, saving cell type name to raise later
            failed_classes.append(adata.obs_names[i])

I checked my changes and they return identical values to Niklas' rework:

# Main branch, no changes
[[1.275371 ]
 [1.0847164]
 [2.2603295]
 [3.0955002]
 [2.2730987]
 [2.19935  ]
 [2.2757301]
 [2.3690026]
...]
# Niklas' branch, no Cas changes
[[1.2876699]
 [1.116849 ]
 [2.307081 ]
 [3.0326943]
 [2.3098855]
 [2.2099555]
 [2.3125596]
 [2.3567338]
...]
# Niklas branch with Cas changes
[[1.2876699]
 [1.116849 ]
 [2.307081 ]
 [3.0326943]
 [2.3098855]
 [2.2099555]
 [2.3125596]
 [2.3567338]
...]

@casblaauw casblaauw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Changes made manually

@nkempynck

Copy link
Copy Markdown
Collaborator Author

thanks cas!

@nkempynck
nkempynck merged commit db72430 into main Aug 18, 2026
12 checks passed
@nkempynck
nkempynck deleted the fix/normalize-peaks-gini-indexing branch August 18, 2026 07:18
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.

4 participants