Skip to content

Optimizations to sex-ploidy adjustment and reducing groups during aggs - #844

Open
mike-w-wilson wants to merge 13 commits into
mainfrom
mw/strata_cells
Open

mike-w-wilson wants to merge 13 commits into
mainfrom
mw/strata_cells

Conversation

@mike-w-wilson

@mike-w-wilson mike-w-wilson commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Two changes that make compute_stats_per_ref_site (per-reference-site AN and coverage over a densified VDS) cheaper to run without changing its output. Both are needed by the gnomAD v5 AoU coverage/AN pipeline.

Breaking change: annotate_and_index_source_mt_for_sex_ploidy now takes the MatrixTable and karyotype expression and returns the annotated MT plus its column and row flag structs, instead of taking a locus expression and indexing the flags back through a cols()/rows() self-join. The only downstream caller is gnomad_qc v4 fix_freq_an.py (line 217). The old behavior is available unchanged under the new public name index_sex_ploidy_flags, so that call can be fixed by renaming it. adjust_sex_ploidy and adjusted_sex_ploidy_expr keep their signatures.

  1. Sex-ploidy adjustment without a second densify. adjusted_sex_ploidy_expr looks up each sample's karyotype and each locus's PAR status by reaching back into the source MatrixTable's columns and rows, which Hail evaluates as a second full densify on a densified VDS. annotate_and_index_source_mt_for_sex_ploidy now annotates those flags directly onto the MT it is given, adjust_sex_ploidy is a thin wrapper over it, and compute_stats_per_ref_site uses that path. The flags themselves are defined once in get_sex_ploidy_col_flags_expr / get_sex_ploidy_row_flags_expr, and the genotype rules are shared with adjusted_sex_ploidy_expr via _sex_ploidy_case_expr. Temporary flag fields use collision-safe names.

  2. Aggregate once per "cell" instead of once per group. Allele-number aggregation previously ran once for every freq_meta group, and the minimal-groups reduction could not help with downsampling groups. The new reduce_to_cells option groups samples by their exact pattern of group memberships. Cells don't overlap and every group is exactly a set of cells, so every group's value, downsamplings included, is recovered by summing its cells. It uses the same reduction globals as before. force_leaf_groups still works, groups with no samples are kept as real leaves, and a freq_meta entry without a "group" key or a force_leaf_groups target not in freq_meta raises up front.

Tests cover both changes, including that cell reconstruction matches both the full aggregation and find_minimal_strata_groups. The /review skill was run with Opus and Sonnet 5; both of its findings were accepted and addressed (the breaking change above is documented, and index_sex_ploidy_flags was made public).

… compute_stats_per_ref_site to avoid the cols()/rows() self-join re-densify
…nd reconstruct every group, downsamplings included, by summing cells
…p IR is linear in cell count

The per-sample cell flags were built as one `cell_of_sample == k`
comparison per cell, and Hail expressions do not share subtrees, so each
comparison re-embedded the literal pattern->cell dict and the pattern
expression: IR quadratic in the number of cells (50 M chars at 600 cells
on a synthetic 10k-sample table). Bind the lookup once per label and
derive the flags with hl.range(...).map(...), assembling the leaf-only
membership from array parts in the same leaf order (forced leaves, then
per label its cells and zero-sample leaves). Same table now yields 84 K
chars with identical values.

Add TestFindStrataCells covering the cell partition/decomposition
invariants and asserting the IR grows linearly with cell count.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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.

🟡 Changes recommended

Missing strata values can break cell reduction, and sex-ploidy temporary fields can overwrite caller data.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Optimizes per-reference-site statistics by avoiding repeated VDS densification and aggregating disjoint membership cells.

Changes:

  • Adds in-place sex-ploidy adjustment using precomputed row/column flags.
  • Adds cell-based stratification reduction, including downsampling groups.
  • Adds correctness and integration tests for both optimizations.
File summaries
File Description
gnomad/sample_qc/sex.py Implements reusable in-place ploidy adjustment.
gnomad/utils/annotations.py Adds cell-based group reduction and reconstruction metadata.
gnomad/utils/sparse_mt.py Uses the optimized ploidy path after densification.
tests/sample_qc/test_sex.py Tests ploidy adjustment behavior.
tests/utils/test_annotations.py Tests cell discovery and expression scaling.
tests/utils/test_sparse_mt.py Tests integration, downsampling, empty groups, and ploidy.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 2
  • Review effort level: Balanced

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

Comment thread gnomad/sample_qc/sex.py Outdated
Comment thread gnomad/utils/annotations.py Outdated
mike-w-wilson and others added 3 commits September 10, 2026 14:59
A sample without a stratification value gets a missing group_membership
bit (hl.all over a missing filter). The group aggregations and
freq_meta_sample_count already treat that as not a member, but the cell
pattern rendered it as the literal "null", shifting every later position
and tripping the per-group cell-sum check. Coalesce the bit to False in
the pattern and in the forced/zero-sample leaf membership.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… IR size note

A label whose groups were all forced (e.g. raw under
force_leaf_groups=[{"group": "raw"}]) still got a cell that nothing
decomposed into, so agg_by_strata aggregated it and discarded the
result. Forced groups are now excluded from cell discovery, so they
neither split cells nor leave orphan cells. The
expand_strata_array_from_leaves docstring now describes the IR size as
proportional to (group, child) pairs, which under reduce_to_cells is up
to n_full x n_cells rather than O(n_full).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@ch-kr ch-kr 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.

thank you for adding this! a few efficiency notes

Comment thread gnomad/sample_qc/sex.py Outdated
"""
return (
hl.case(missing_false=True)
# Added to reduce the checks by entry.

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.

I didn't realize this comment was copied and pasted. I'm not sure I understand what it means; maybe we should just remove it?

Comment thread gnomad/sample_qc/sex.py
return (
hl.case(missing_false=True)
# Added to reduce the checks by entry.
.when(row_flags.in_autosome, gt_expr)

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.

I know this was copied and pasted from below, but maybe this flag should be in_autosome_or_par, which would remove the need for .when(~row_flags.in_non_par, gt_expr) below

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.

I changed my mind since this would also mean updating

def annotate_and_index_source_mt_for_sex_ploidy(
for a pretty trivial change, but I left these as comments in case this comes up again

Comment thread gnomad/sample_qc/sex.py
MatrixTable's ``cols()`` and ``rows()`` (see
`annotate_and_index_source_mt_for_sex_ploidy`). Hail evaluates those
self-joins as a second pass over the source MatrixTable's upstream
pipeline, so when the source is expensive to compute (e.g. a densified

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.

maybe it would be better to update annotate_and_index_source_mt_for_sex_ploidy directly rather than creating a second option to adjust sex ploidy? it is a little against our usual conventions for this repo, but that function could take the source MT as input (rather than deriving the source using Hail internal code) and return column/row expressions so that these extra joins are never computed, regardless of dataset size. this would remove redundant looking code in adjust_sex_ploidy but would be a breaking change

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good call, I took your advice and shortened things up a bit with helpers too. Like you said its a breaking change for the annotate_and_index with the change in params but currently (internally) only fix_freq_an in gnomad_qc/v4 uses this. We can mark this as a breaking change anyways and its an easy caller update for anyone who actually uses it. Worth the optimization to me.

Comment thread gnomad/utils/annotations.py Outdated
labels: List[str] = []
idx_by_label: Dict[str, List[int]] = {}
for i, m in enumerate(freq_meta):
label = m.get("group")

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.

group will always be defined for gnomad, but gemini flagged this:

The Issue:
If any dictionary in freq_meta is missing the "group" key, m.get("group") will return None.
Later, when you attempt to unpack the dictionary into hl.struct(**{...}), Python will encounter {None: hl.agg.counter(...)}. In Python, unpacking a dictionary as keyword arguments strictly requires the keys to be strings. This will instantly throw a TypeError: keywords must be strings.
The Fix:
Ensure a default string fallback or filter out missing groups.

Comment thread gnomad/utils/annotations.py Outdated
raise ValueError(
f"`force_leaf_groups` entry {target} is not in `freq_meta`."
)
forced.extend(matches)

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.

gemini:

If force_leaf_groups contains duplicate target metadata dictionaries, forced.extend(matches) will append the same indices multiple times. This leads to duplicate leaf_indices and redundant checks.
Fix: Use a set or deduplicate forced before proceeding: forced = list(set(forced_raw)).

Comment on lines +81 to +82
assert by_key[("chrX", 20000, "s2")] == hl.Call([1, 1])
assert by_key[("chrX", 155800000, "s3")] == hl.Call([0, 0])

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.

maybe one of these genotypes should be het, just to make this test more thorough, since s2 and s3 are both XY

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yes good point

Comment thread gnomad/utils/annotations.py Outdated
force_leaf_groups = force_leaf_groups or []
forced = []
for target in force_leaf_groups:
matches = [i for i, m in enumerate(freq_meta) if m == target]

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.

minor, but this check could be combined with the for loop enumerating freq_meta below. if you find i in force_leaf_groups, add it to forced, and then check the difference between force_leaf_groups and forced at the end of the loop. this would do a little extra work, since you'd build labels and idx_by_label but hit an error if any entries in force_leaf_groups aren't in freq_meta, but it would clean this up slightly

Comment on lines +2469 to +2476
def _pattern_expr(label: str) -> hl.expr.StringExpression:
return hl.delimit(
[
hl.if_else(hl.coalesce(ht.group_membership[i], False), "1", "0")
for i in idx_by_label[label]
],
"",
)

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.

from gemini:

In your _pattern_expr function, you are using a Python list comprehension ([... for i in ...]) instead of Hail's native .map(). You must convert the Python list of indices into a Hail array literal and map over it natively. This shifts the loop execution to the JVM:

# FIXED (Executes natively in Hail Engine)
def _pattern_expr(label: str) -> hl.expr.StringExpression:
    indices_array = hl.literal(idx_by_label[label])
    return hl.delimit(
        indices_array.map(
            lambda i: hl.if_else(hl.coalesce(ht.group_membership[i], False), "1", "0")
        ),
        "",
    )```



class TestFindStrataCells:
"""Test the find_strata_cells function."""

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.

none of the tests below seem to test running find_strata_cells with force_leaf_groups set; this should probably be covered.

I also don't know if I missed this, but should there be a test ensuring that the output of find_strata_cells and find_minimal_strata_groups are equivalent?

mike-w-wilson and others added 4 commits September 14, 2026 10:47
Co-authored-by: Katherine Chao <kchao@broadinstitute.org>
…pass force_leaf_groups handling in find_strata_cells, and cell reconstruction tests

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
… and return it annotated with the flags so adjust_sex_ploidy needs no self-join and the flags are defined once

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
…s documented and importable by downstream callers

Assisted-by: Claude Fable 5.1 <noreply@anthropic.com>
@mike-w-wilson

Copy link
Copy Markdown
Contributor Author

back to you @ch-kr ! I took your suggestion around updating the annotate_and _index function instead to remove bloat and rewrote the PR description to include the breaking change.

@mike-w-wilson

Copy link
Copy Markdown
Contributor Author

Ive added broadinstitute/gnomad_qc#787 which fixes the QC check. I originally was not going to change it but figured its simple change is easier than getting around the action.

@ch-kr ch-kr 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.

two comments about test, and one separate efficiency issue that claude flagged

return leaf_indices, decomposition


def find_strata_cells(

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.

from claude, though it noted this doesn't need to be fixed as part of this PR since it existed before 844

Issue. In agg_by_strata, an entry_agg_group_membership target that resolves to a parent (not a leaf) builds its sample index list inline in the per-row expression (gnomad/utils/annotations.py:3458), by filtering all samples against every child position. Leaf targets read the precomputed indices_by_group global instead (annotations.py:3388). Hail does not hoist global-only sub-expressions out of a row map, so the parent list is rebuilt at every reference site at a cost of n_samples × n_children. Quick check (global-only filter over a 100k array, inline in annotate vs precomputed with annotate_globals):

rows inline in row expr precomputed global
2,000 0.6 s 0.2 s
40,000 2.5 s 0.5 s

This path predates the PR, but reduce_to_cells makes it heavier: under cells {"group": "adj"} is never a leaf, so it decomposes into every adj cell. Today the only parent pin is gnomAD coverage_stats -> {"group": "adj"} with ~800 samples and ~20 cells, so the current cost is negligible. On the AoU run (~400k samples, hundreds of downsampling cells) any future pin to adj would add 400k × n_cells work per site and give back a large share of the cell savings. Forcing {"group": "adj"} as a leaf avoids the scan but adds an extra all-samples AN aggregation per site, so it is the wrong fix for AoU.

Suggestion. Compute the per-target parent index lists once as globals and have the row expression read them. Output is unchanged; also speeds up the existing minimal-groups parent path.

ht = ht.annotate_globals(**global_expr)
if entry_agg_group_membership:
    ht = ht.annotate_globals(
        **{
            f"_{ann}_targets": hl.struct(
                **dict(zip(("s_indices", "adj"), _per_target_indices_and_adj(targets)))
            )
            for ann, targets in entry_agg_group_membership.items()
        }
    )

def _agg_for(ann, f):
    if ann in entry_agg_group_membership:
        t = ht[f"_{ann}_targets"]
        return _agg_by_group(t.s_indices, t.adj, agg_func=f[1], ann_expr=ht[ann])
    ...

(_per_target_indices_and_adj needs to be defined before the annotate_globals, and the _*_targets globals dropped with cols at the end.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added since this is already about optimizations.



class TestFindStrataCells:
"""Test the find_strata_cells function."""

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.

I asked claude if there were any missing tests that were important:

A pinned parent with many cells. The production gnomAD path pins coverage_stats to {"group": "adj"}, which under cells is a parent of every adj cell. The only cells-mode pin tested is max_called -> {"group": "raw"} in test_sparse_mt.py:966, and the raw label collapses to one cell, so the multi-child parent path in agg_by_strata is never exercised under cells. Add a non-summable agg pinned to {"group": "adj"} or {"group": "adj", "gen_anc": "afr"} and compare to the full run. The hl.agg.hist parent test at test_sparse_mt.py:703 covers this under minimal groups only, and the IR-shape bug it guards is exactly the kind that could differ with hundreds of children.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added however "many" is only 8 here. We can increase it but I'm not sure its needed.

return col_ht[source_mt.col_key], row_ht[source_mt.row_key]


def get_is_haploid_expr(

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.

since this function was updated, should we add a test?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Added!

…strata; make index_sex_ploidy_flags return only the flags; add pinned-parent cells and index_sex_ploidy_flags tests
@mike-w-wilson
mike-w-wilson requested a review from ch-kr September 18, 2026 17:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants